Compare commits

..

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

127 changed files with 10277 additions and 10642 deletions

View File

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

View File

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

View File

@ -14,7 +14,7 @@ jobs:
php:
runs-on: ubuntu-latest
container: nextcloud:31
container: nextcloud:29
steps:
- run: apt-get update
- run: apt-get install -y git nodejs
@ -24,7 +24,7 @@ jobs:
- run: composer install
- run: composer run lint
- run: composer run cs:check
- run: composer run phpstan
- run: composer run psalm
nodejs:
runs-on: ubuntu-latest
@ -38,7 +38,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: ${{ steps.versions.outputs.nodeVersion }}
- run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
- run: npm i -g npm@${{ steps.versions.outputs.npmVersion }}
- run: npm ci
- run: npm run lint
- run: npm run stylelint
@ -47,7 +47,7 @@ jobs:
release:
if: gitea.ref_type == 'tag'
runs-on: ubuntu-latest
container: nextcloud:31
container: nextcloud:29
steps:
- run: apt-get update
- run: apt-get install -y git nodejs
@ -62,12 +62,12 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: ${{ steps.versions.outputs.nodeVersion }}
- run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
- run: npm i -g npm@${{ steps.versions.outputs.npmVersion }}
- run: make dist
- uses: akkuman/gitea-release-action@v1
with:
files: |
build/artifacts/${{ gitea.event.repository.name }}.tar.gz
build/artifacts/repod.tar.gz
- uses: FKLC/sign-files-action@v1.0.0
with:
privateKey: ${{ secrets.PRIVATEKEY }}
@ -75,13 +75,13 @@ jobs:
extension: .sig
outputFolder: build/artifacts
files: |
build/artifacts/${{ gitea.event.repository.name }}.tar.gz
build/artifacts/repod.tar.gz
- 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
with:
url: https://apps.nextcloud.com/api/v1/apps/releases
method: POST
accept: 200,201
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/
/js/
/css/
/build/

View File

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

View File

@ -13,7 +13,9 @@ class MyConfig extends Config
$rules = parent::getRules();
$rules['@PhpCsFixer'] = true;
$rules['curly_braces_position']['classes_opening_brace'] = 'next_line_unless_newline_at_signature_end';
$rules['phpdoc_separation'] = false;
$rules['phpdoc_to_comment'] = false;
$rules['single_line_comment_style'] = false;
return $rules;
}
}

View File

@ -1,195 +1,4 @@
## 3.5.7 - Prince of Persia - 2025-04-24
### Added
- 🔍 You can now search and filter your subscriptions to quickly find what you want to listen
- 🌐 Add Persian language (thanks to @alr86)
### Changed
- ⬆️ Update @nextcloud/vue and many other dependencies
## 3.5.6 - March comes in like a lion - 2025-03-05
### Changed
- ⬆️ Update @nextcloud/vue
### Fixed
- 🮰 No pointer on play / stop icon on episodes list
[#264](https://git.crystalyx.net/Xefir/repod/issues/264)
## 3.5.5 - Alone in the dark - 2025-02-14
### Changed
- ⬆️ Update @nextcloud/vue
- 🔖 Support Nextcloud 31
- 🌐 Updated german language (thanks markus phi)
### Fixed
- 🔇 Cancel notification when stopping playback
## 3.5.4 - Under the spotlight - 2025-01-03
### Added
- 🧑‍🎤 Added the episode author on the list and modal
- ✨ Added cover image and episode infos on desktop and mobile notifications
## 3.5.3 - Hangover - 2025-01-03
### Fixed
- 🚑 Regression on filters
## 3.5.2 - Happy New Year - 2025-01-01
### Changed
- 🌐 Updated german language (thanks markus phi)
🎉 Happy new year !
## 3.5.1 - Merry Christmas - 2024-12-24
### Fixed
- 🐛 Filters reseted on page reload, now working.
[#231](https://git.crystalyx.net/Xefir/repod/issues/231) reported by @nolan
🎁 Merry Christmas everyone !
## 3.5.0 - Select all peaceful moments, unselect sadness - 2024-12-11
### Added
- ✨ Select severall episodes and mark them as read/unread
> Click on an episode cover to select it 😉
- 🌐 New platform to manage and help to translate RePod
> You can contribute to translate the app in your language and you don't need to have any development background to do so !
> Please join the effort at our **[Weblate](https://translate.crystalyx.net/projects/repod/gitea/)** project.
> Thank you so much if you decide to participate ❤️
## 3.4.1 - Skip & Chill - 2024-11-12
### Changed
- 💄 Make a little gap between player's controls
### Fixed
- 📝 Descriptions are now well formatted
- ⏩ Chapters supported !
> Click on a timestamp in descriptions to skip to the specified part of the podcast
## 3.4.0 - Good Night - 2024-11-09
### Added
- ♿ Improve accessibility by adding titles when missing
- ✨ Playback speed and volume setting doesn't stick
[#185](https://git.crystalyx.net/Xefir/repod/issues/185) reported by @SteveDinn
- ✨ Skip back or forward
[#159](https://git.crystalyx.net/Xefir/repod/issues/159) reported by @moonlike8812
- ✨ Sleep timer
[#119](https://git.crystalyx.net/Xefir/repod/issues/119) reported by @Markusphi and @OiledAmoeba
### Changed
- 💄 Add padding around favorites on mobile
### Fixed
- 🔒 Increase security when Nextcloud is in debug mode
## 3.3.2 - What a Nightmare - 2024-10-24
### Fixed
- 🚑 Revert [#178](https://git.crystalyx.net/Xefir/repod/issues/178) not working on big subscriptions lists
[#182](https://git.crystalyx.net/Xefir/repod/issues/182) reported by @SteveDinn
## 3.3.1 - Breaking the Loop - 2024-10-24
### Changed
- ⚡ Speed up the loading time of subscriptions
[#178](https://git.crystalyx.net/Xefir/repod/issues/178) reported by @MikeAndrews
### Fixed
- 🐛 Prevent Firefox for going nuts when having Plasma Integration addon installed
[#164](https://git.crystalyx.net/Xefir/repod/issues/164) reported by @cichy1173, @Share1440 and @mark.collins
## 3.3.0 - Into The Jet Lag - 2024-10-18
### Changed
- 🧑‍💻 CSS isn't mixed in the main JS file anymore
### Fixed
- 🐛 App won't load on Firefox 115
[#158](https://git.crystalyx.net/Xefir/repod/issues/158) reported by @Jaunty and @mark.collins
- 🔇 Volume slider didn't work properly
### Deprecated
- 💣 Require Nextcloud 29 or more
## 3.2.0 - Typing fast - 2024-09-15
### Added
- 📝 Add Cardo to list of compatible clients
[#176](https://github.com/thrillfall/nextcloud-gpodder/pull/176) reported by @n0vella
### Changed
- 🧑‍💻 Switch entiere project to TypeScript
### Fixed
- 💄 Missing icon on home when aren't any favorites
- 💄 Tweaks spacing in several spaces on Home and banners
- 💩 Leverage the available space between the episode title and the play button (but hacky way for now)
[#59](https://git.crystalyx.net/Xefir/repod/issues/59#issuecomment-6246) reported by @W_LL_M
## 3.1.0 - Above the stars - 2024-09-02
### Added
- ⭐ You can now add favorites subscriptions !
It will show's up on the homepage instead of the recommendations witch appear only when you add a new subscription.
[#59](https://git.crystalyx.net/Xefir/repod/issues/59) suggested by @W_LL_M, @Jaunty and @Satalink
### Changed
- 💥 Use html5 routing instead of hashes. All the URLs has changed removing the `#/` part.
### Fixed
- 🐛 Regression on 3.0 that prevent seeking player to episode last listened position
[#136](https://git.crystalyx.net/Xefir/repod/issues/136) reported by @randomuser1967
- ⚡ Improve the detection off mis-installed or mis-enabled gpodder app
## 3.0.0 - What a vue - 2024-08-17
### Added
- 🌐 Add german translation
Thanks to @OiledAmoeba [#120](https://git.crystalyx.net/Xefir/repod/issues/120)
### Changed
- 🎉 Migrate to Vue 3
- 🔖 Support Nextcloud 30
- 🏗️ Switch from Vuex to Pinia
### Fixed
- 💄 Use iTunes image first for episode if available
- 💄 Displaying styles and proper HTML on episode's modal descriptions
### Removed
- 🗑️ Temporary replacing @nextcloud/dialogs to toastyjs
## 2.3.3 - The Cake is a Lie - 2024-06-14
### Changed
- ⬆️ Update @nextcloud/dialogs to 5.3.2
### Fixed
- 🐛 App crashed when no cache system available
[#107](https://git.crystalyx.net/Xefir/repod/issues/107) reported by @skvaller and @PhilTraere
## 2.3.2 - Young Youth - 2024-05-31
### Fixed
- 🐛 New subscribe button on search not disapearing if subscribed
- ♿ Missing accessibility label on this button as well
## 2.3.1 - Powerwash the Universe - 2024-05-29
### Changed
- ⚡ Reduce app size by not shipping sourcemap
## 2.3.0 - Star Align - 2024-05-29
## 2.3.0 - 2024-05-29
### Added
- Ability to subscribe to podcast from search list
@ -206,12 +15,12 @@ Thanks to @OiledAmoeba [#120](https://git.crystalyx.net/Xefir/repod/issues/120)
- ⚰️ Drop support for PHP 8.0
- 🌐 Removed babel
## 2.2.1 - Shami was here - 2024-05-18
## 2.2.1 - 2024-05-18
### Removed
- ♻️ 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
- 🚨 Linting the code with ESLint
@ -223,7 +32,7 @@ Thanks to @OiledAmoeba [#120](https://git.crystalyx.net/Xefir/repod/issues/120)
### Fixed
- 🔓 Hide unreadable episodes because of insecure sources
## 2.1.0 - Pocket Gundams - 2024-03-16
## 2.1.0 - 2024-03-16
### Added
- 🔍 Add CTA for rating the app on the store
@ -237,7 +46,7 @@ Thanks to @OiledAmoeba [#120](https://git.crystalyx.net/Xefir/repod/issues/120)
- 🔒 App wasn't working for non admin users
[#76](https://git.crystalyx.net/Xefir/repod/issues/76) reported by @devasservice
## 2.0.0 - Taking Actions - 2024-03-05
## 2.0.0 - 2024-03-05
### Added
- 🍪 Saving filters preference
@ -254,13 +63,13 @@ Thanks to @OiledAmoeba [#120](https://git.crystalyx.net/Xefir/repod/issues/120)
### Fixed
- ❤️‍🔥 Better handling ended episodes
## 1.5.9 - Just According to Keikaku - 2024-02-21
## 1.5.9 - 2024-02-21
### Changed
- 🧑‍💻 Change some endpoints to match gPodder.net "specifications"
- ⬆️ Update @nextcloud/vue to 8.7.0
## 1.5.8 - Goblet of Eonothem - 2024-02-11
## 1.5.8 - 2024-02-11
### Fixed
- Fyyd API sometime send empty feeds, ignoring them
@ -278,19 +87,19 @@ Thanks to @OiledAmoeba [#120](https://git.crystalyx.net/Xefir/repod/issues/120)
### Changed
- Update @nextcloud/vue to v8.6.2
## 1.5.5 - Hide and seek - 2024-02-04
## 1.5.5 - 2024-02-04
### Fixed
- Can't open podcast details if cache missing or misconfigured
[#58](https://git.crystalyx.net/Xefir/repod/issues/58) reported by @raxventus
## 1.5.4 - In search of the truth - 2024-02-03
## 1.5.4 - 2024-02-03
### Fixed
- Nextcloud search engine didn't work on Nextcloud 26 and 27
[#57](https://git.crystalyx.net/Xefir/repod/issues/57) reported by @JonOfUs
## 1.5.3 - The date where it all ends - 2024-02-01
## 1.5.3 - 2024-02-01
### Changed
- Update @nextcloud/vue to v8.6.1
@ -298,7 +107,7 @@ Thanks to @OiledAmoeba [#120](https://git.crystalyx.net/Xefir/repod/issues/120)
### Fixed
- Fix episode listing crashing if an invalid publication date is present in the RSS
## 1.5.2 - A little to the top - 2024-02-01
## 1.5.2 - 2024-02-01
### Changed
- Update @nextcloud/router to v3.0.0
@ -306,7 +115,7 @@ Thanks to @OiledAmoeba [#120](https://git.crystalyx.net/Xefir/repod/issues/120)
### Fixed
- 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
- Update @nextcloud/vue to v8.6.0
@ -316,7 +125,7 @@ Thanks to @OiledAmoeba [#120](https://git.crystalyx.net/Xefir/repod/issues/120)
### Fixed
- 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
- Filtering options for each podcast section

9
CODE_OF_CONDUCT.md Normal file
View File

@ -0,0 +1,9 @@
In the Nextcloud community, participants from all over the world come together to create Free Software for a free internet. This is made possible by the support, hard work and enthusiasm of thousands of people, including those who create and use Nextcloud software.
Our code of conduct offers some guidance to ensure Nextcloud participants can cooperate effectively in a positive and inspiring atmosphere, and to explain how together we can strengthen and support each other.
The Code of Conduct is shared by all contributors and users who engage with the Nextcloud team and its community services. It presents a summary of the shared values and “common sense” thinking in our community.
You can find our full code of conduct on our website: https://nextcloud.com/code-of-conduct/
Please, keep our CoC in mind when you contribute! That way, everyone can be a part of our community in a productive, positive, creative and fun way.

View File

@ -1,11 +1,10 @@
FROM nextcloud:31
FROM nextcloud:29
ARG APP_NAME=repod
ENV NEXTCLOUD_UPDATE=1
ENV NEXTCLOUD_ADMIN_USER=$APP_NAME
ENV NEXTCLOUD_ADMIN_PASSWORD=$APP_NAME
ENV NEXTCLOUD_INIT_HTACCESS=1
ENV SQLITE_DATABASE=$APP_NAME
ENV NEXTCLOUD_UPDATE 1
ENV NEXTCLOUD_ADMIN_USER repod
ENV NEXTCLOUD_ADMIN_PASSWORD repod
ENV NEXTCLOUD_INIT_HTACCESS 1
ENV SQLITE_DATABASE repod
RUN apt-get update && \
apt-get install -y nodejs npm sqlite3 && \
@ -14,10 +13,10 @@ RUN apt-get update && \
USER www-data
COPY --chown=www-data:www-data . apps/$APP_NAME
RUN cd apps/$APP_NAME && make build && cd - && \
COPY --chown=www-data:www-data . apps/repod
RUN cd apps/repod && make build && cd - && \
php occ app:install gpoddersync && \
php occ app:enable $APP_NAME && \
php occ app:enable repod && \
php occ config:system:set debug --value=true
USER root

103
Makefile
View File

@ -1,4 +1,39 @@
# 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))
build_tools_directory=$(CURDIR)/build/tools
@ -68,14 +103,14 @@ dist: build
source:
rm -rf $(source_build_directory)
mkdir -p $(source_build_directory)
tar -C .. -cvzf $(source_package_name).tar.gz \
tar cvzf $(source_package_name).tar.gz \
--exclude-vcs \
--exclude="$(app_name)/build" \
--exclude="$(app_name)/js/node_modules" \
--exclude="$(app_name)/node_modules" \
--exclude="$(app_name)/*.log" \
--exclude="$(app_name)/js/*.log" \
$(app_name)
--exclude="../$(app_name)/build" \
--exclude="../$(app_name)/js/node_modules" \
--exclude="../$(app_name)/node_modules" \
--exclude="../$(app_name)/*.log" \
--exclude="../$(app_name)/js/*.log" \
../$(app_name)
# Builds the source package for the app store, ignores php tests, js tests
# and build related folders that are unnecessary for an appstore release
@ -83,24 +118,50 @@ source:
appstore:
rm -rf $(appstore_build_directory)
mkdir -p $(appstore_build_directory)
tar -C .. -cvzf $(appstore_package_name).tar.gz \
$(app_name)/appinfo \
$(app_name)/css \
$(app_name)/img \
$(app_name)/js \
$(app_name)/l10n \
$(app_name)/lib \
$(app_name)/templates \
$(app_name)/CHANGELOG.md
tar cvzf $(appstore_package_name).tar.gz \
--exclude="../$(app_name)/build" \
--exclude="../$(app_name)/tests" \
--exclude="../$(app_name)/Makefile" \
--exclude="../$(app_name)/*.log" \
--exclude="../$(app_name)/phpunit*xml" \
--exclude="../$(app_name)/composer.*" \
--exclude="../$(app_name)/node_modules" \
--exclude="../$(app_name)/js/node_modules" \
--exclude="../$(app_name)/js/tests" \
--exclude="../$(app_name)/js/test" \
--exclude="../$(app_name)/js/*.log" \
--exclude="../$(app_name)/js/package.json" \
--exclude="../$(app_name)/js/bower.json" \
--exclude="../$(app_name)/js/karma.*" \
--exclude="../$(app_name)/js/protractor.*" \
--exclude="../$(app_name)/package.json" \
--exclude="../$(app_name)/bower.json" \
--exclude="../$(app_name)/karma.*" \
--exclude="../$(app_name)/protractor\.*" \
--exclude="../$(app_name)/.*" \
--exclude="../$(app_name)/js/.*" \
--exclude="../$(app_name)/webpack.js" \
--exclude="../$(app_name)/stylelint.config.js" \
--exclude="../$(app_name)/README.md" \
--exclude="../$(app_name)/package-lock.json" \
--exclude="../$(app_name)/LICENSE" \
--exclude="../$(app_name)/src" \
--exclude="../$(app_name)/stubs" \
--exclude="../$(app_name)/vendor" \
--exclude="../$(app_name)/translationfiles" \
--exclude="../$(app_name)/Dockerfile" \
--exclude="../$(app_name)/psalm.xml" \
--exclude="../$(app_name)/renovate.json" \
../$(app_name)
# Start a nextcloud server on Docker to kickstart developement
.PHONY: dev
dev: build
docker stop $(app_name) || true
docker rm $(app_name) || true
docker build -t $(app_name) .
docker run -itd --rm --name $(app_name) -v $(CURDIR):/var/www/html/apps/$(app_name) -p 80:80 $(app_name)
npm run watch || docker stop $(app_name)
docker stop repod || true
docker rm repod || true
docker build -t repod .
docker run -itd --rm --name repod -v $(CURDIR):/var/www/html/apps/repod -p 80:80 repod
npm run watch
# Generate translations
.PHONY: l10n

View File

@ -6,12 +6,13 @@ You need to have [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) inst
## Features
- 🔍 Browse and subscribe huge collection of podcasts
- 🔊 Listen to episodes directly in Nextcloud
- 📋 Keep track of subscribed shows and episodes
- 🌐 Sync them with GPodderSync compatible clients
- 📱 Mobile friendly interface
- 📡 Import and export your subscriptions
- Browse podcasts and play them directly in Nextcloud
- Keep track of subscribed shows and episodes
- Sync them with GPodderSync compatible clients
- Import and export subscriptions
- Mobile friendly interface
- Unified search integration
- Interface in multiple languages
## Comparaison with similar apps for Nextcloud
@ -19,32 +20,30 @@ You need to have [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) inst
| --- | --- | --- | --- | --- |
| Actively maintened | ✅ | ✅ | ✅ | ❌ |
| Play your local music files | ❌ | ❌ | ✅ | ❌ |
| Sync with [GPodder clients](#clients-supporting-sync-of-gpoddersync) | ✅ | ✅ | [](https://github.com/owncloud/music/issues/975) | [](https://git.project-insanity.org/onny/nextcloud-app-podcast/-/issues/103) |
| Sync with [GPodder clients](#clients-supporting-sync-of-gpoddersync) | ✅ | ✅ | ❌ | ❌ |
| Add and manage subscriptions | ✅ | ❌ | ✅ | ✅ |
| Listen synced episodes by another clients | ✅ | ✅ | ❌ | ❌ |
| Fetch and listen new epidodes | ✅ | [](https://github.com/pbek/nextcloud-nextpod/issues/5) | ✅ | ✅ |
| Keep track of listened episodes | ✅ | ✅ | [](https://github.com/owncloud/music/issues/1148) | ✅ |
| Download epidodes | ✅ | ✅ | ❌ | ✅ |
| Sleep timer | ✅ | ❌ | [](https://github.com/owncloud/music/issues/884#issuecomment-921582302) | ❌ |
| Advanced player controls | ✅ | ❌ | ✅ | ✅ |
| Import and export subscriptions | ✅ | ❌ | [](https://github.com/owncloud/music/issues/904) | [](https://git.project-insanity.org/onny/nextcloud-app-podcast/-/issues/185) |
| Fetch and listen new epidodes | ✅ | ❌ | ✅ | ✅ |
| Keep track of listened episodes | ✅ | ✅ | ❌ | ✅ |
| Import and export subscriptions | ✅ | ❌ | ❌ | ❌ |
| Search and discover new podcasts | ✅ | ❌ | ❌ | ✅ |
| Open episode website and RSS feed | ✅ | ✅ | ❌ | ✅ |
| Integrate with Nextcloud search engine | ✅ | ❌ | ❌ | ✅ |
| Integrate with [Nextcloud Notes](https://apps.nextcloud.com/apps/notes) | ❌ | ✅ | ❌ | ❌ |
| Mobile friendly interface | ✅ | ❌ | ✅ | ✅ |
| Support chapters | ✅ | ❌ | ❌ | ✅ |
| Available in multiple languages | [](https://translate.crystalyx.net/projects/repod/gitea/) (5) | ❌ | [](https://github.com/owncloud/music/issues/671#issuecomment-782746463) | [](https://www.transifex.com/project-insanityorg/podcast-1/dashboard/) (2) |
> Click on ⭕ to open the ticket
| Support chapters | ❌ | ❌ | ❌ | ✅ |
## Screenshots
<img src="./screens/index.png" width="230" title="Homepage" />
<img src="./screens/discover.png" width="230" title="Discover" />
<img src="./screens/search.png" width="230" title="Search" />
<img src="./screens/episodes.png" width="230" title="Episode list" />
<img src="./screens/modal.png" width="230" title="Episode description" />
### Homepage
![homepage](./screens/index.png)
### Search
![search](./screens/search.png)
### Episode list
![episodes](./screens/episodes.png)
### Episode description
![modal](./screens/modal.png)
## Clients supporting sync of GPodderSync
@ -53,27 +52,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/). |
| [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 |
| [Cardo](https://cardo-podcast.github.io/#/cardo) | Podcast client with sync support, for Windows, Mac and Linux |
## 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.
## Known issues
- Conflict with Plasma Integration Firefox addon ([#164](https://git.crystalyx.net/Xefir/repod/issues/164))
## Translations
You can contribute to translate the app in your language and you don't need to have any development background to do so !
Please join the effort at our **[Weblate](https://translate.crystalyx.net/projects/repod/gitea/)** project.
- [Start a new language](https://translate.crystalyx.net/new-lang/repod/gitea/)
- [Contribute to missing translations](https://translate.crystalyx.net/search/repod/gitea/?q=NOT+is%3Atranslated&sort_by=-priority%2Cposition&checksum=)
Thank you so much if you decide to participate ❤️
## Credits
- [GPodder Sync](https://github.com/thrillfall/nextcloud-gpodder) for the database API

View File

@ -7,14 +7,11 @@
<description><![CDATA[## Features
- 🔍 Browse and subscribe huge collection of podcasts
- 🔊 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)
- 📱 Mobile friendly interface
- 📡 Import and export your subscriptions
- ➡️ Full features comparison [here](https://git.crystalyx.net/Xefir/repod#comparaison-with-similar-apps-for-nextcloud)
- 🌐 Sync your activity with [AntennaPod](https://antennapod.org/)
## Requirements
You need to have [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installed to use this app!]]></description>
<version>3.5.7</version>
<version>2.3.0</version>
<licence>agpl</licence>
<author mail="xefir@crystalyx.net" homepage="https://crystalyx.net">Michel Roux</author>
<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>
<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/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/episodes.png</screenshot>
<screenshot>https://git.crystalyx.net/Xefir/repod/raw/branch/main/screens/modal.png</screenshot>
<dependencies>
<php min-version="8.1"/>
<nextcloud min-version="29" max-version="31"/>
<nextcloud min-version="27" max-version="29"/>
</dependencies>
<navigations>
<navigation>
<id>repod</id>
<name>Podcast</name>
<route>repod.page.index</route>
<icon>app.svg</icon>
<type>link</type>
</navigation>
</navigations>
</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

@ -5,28 +5,20 @@
"autoload": {
"psr-4": {
"OCA\\RePod\\": "lib/",
"OCA\\GPodderSync\\": "stubs/OCA/GPodderSync/",
"OCP\\": "vendor/nextcloud/ocp/OCP/"
"OCA\\GPodderSync\\": "stubs/OCA/GPodderSync/"
}
},
"scripts": {
"lint": "find . -name \\*.php -not -path './vendor/*' -not -path './vendor-bin/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l",
"cs:check": "php-cs-fixer fix --dry-run --diff",
"cs:fix": "php-cs-fixer fix",
"phpstan": "phpstan clear-result-cache && phpstan analyse -c phpstan.neon",
"rector": "rector && composer cs:fix"
},
"require": {
"php": "^8.1"
"psalm": "psalm --threads=1 --no-cache --show-info=true"
},
"require-dev": {
"nextcloud/coding-standard": "^1.3.2",
"nextcloud/ocp": "^v31.0.4",
"nextcloud/rector": "^0.4.1",
"phpstan/phpstan": "^2.1.12",
"phpstan/phpstan-deprecation-rules": "^2.0.1",
"rector/rector": "^2.0.12",
"roave/security-advisories": "dev-latest"
"nextcloud/ocp": "^29.0.1",
"roave/security-advisories": "dev-latest",
"nextcloud/coding-standard": "^1.2.1",
"vimeo/psalm": "^5.24.0"
},
"config": {
"optimize-autoloader": true,

2529
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,66 +0,0 @@
OC.L10N.register(
"repod",
{
"RePod Subscriptions" : "RePod Abonnements",
"Podcast" : "Podcast",
"RePod" : "RePod",
"🔊 Browse, manage and listen to podcasts" : "🔊 Suchen, Verwalten und Anhören von Podcasts",
"## Features\n- 🔍 Browse and subscribe huge collection of podcasts\n- 🔊 Listen to episodes directly in Nextcloud\n- 🌐 Sync your activity with [AntennaPod](https://antennapod.org/) and [other apps](https://git.crystalyx.net/Xefir/repod#clients-supporting-sync-of-gpoddersync)\n- 📱 Mobile friendly interface\n- 📡 Import and export your subscriptions\n- ➡️ Full features comparison [here](https://git.crystalyx.net/Xefir/repod#comparaison-with-similar-apps-for-nextcloud)\n\n## Requirements\nYou need to have [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installed to use this app!" : "## Funktionen\n- 🔍 Durchsuchen und abonnieren einer großen Sammlung von Podcasts\n- 🔊 Episoden direkt in Nextcloud anhören\n- 🌐 Synchronisiere deine Aktivität mit [AntennaPod](https://antennapod.org/)\n- 📱 Handy-freundliche Schnittstelle\n- 📡 Importieren und Exportieren Ihrer Abonnements\n- ➡️ Vollständiger Funktionsvergleich [hier](https://git.crystalyx.net/Xefir/repod#comparaison-with-similar-apps-for-nextcloud)\n\n## Voraussetzungen\nDu musst [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installiert haben, um diese App zu benutzen!",
"Download" : "Herunterladen",
"Skip to {match}" : "Springe zu {match}",
"Add a RSS link" : "Einen RSS-Link hinzufügen",
"Subscribe" : "Abonnieren",
"Error while adding the feed" : "Fehler beim Hinzufügen des Feeds",
"Could not fetch search results" : "Suchergebnisse können nicht geladen werden",
"New podcasts" : "Neue Podcasts",
"Hot podcasts" : "Beliebte Podcasts",
"Could not fetch tops" : "Titel können nicht abgerufen werden",
"Copy feed" : "Feed kopieren",
"Link copied to the clipboard" : "Link in die Zwischenablage kopiert",
"Play" : "Abspielen",
"Stop" : "Stopp",
"Read" : "gehört",
"Open website" : "Webseite aufrufen",
"Select" : "Auswählen",
"Could not change the status of the episode" : "Kann den Status der Folge nicht ändern",
"Read all" : "Alles lesen",
"Unread all" : "Ungelesen",
"Select all" : "Alle auswählen",
"Unselect all" : "Alles abwählen",
"Could not fetch episodes" : "Folgen können nicht abgerufen werden",
"_%n episode selected_::_%n episodes selected_" : ["%n Folge ausgewählt","%n Folgen ausgewählt"],
"Rewind 10 seconds" : "10 Sekunden zurück",
"Pause" : "Pause",
"Fast forward 30 seconds" : "30 Sekunden vor",
"Mute" : "Stumm",
"Unmute" : "Stummschalten aufheben",
"Export subscriptions" : "Abonnements exportieren",
"Filtering episodes" : "Folgen filtern",
"Show all" : "Zeige alles",
"Listened" : "Gehört",
"Listening" : "Läuft",
"Unlistened" : "Nicht angehört",
"Import subscriptions" : "Importiere Abonnements",
"Import OPML file" : "Importiere OPML-Datei",
"Rate RePod ❤️" : "Bewerte RePod ❤️",
"Settings" : "Einstellungen",
"Sleep timer" : "Einschlaftimer",
"Minutes" : "Minuten",
"_%n min_::_%n mins_" : ["%n Minute","%n Minuten"],
"_%n sec_::_%n secs_" : ["%n sec","%n secs"],
"Playback speed" : "Wiedergabegeschwindigkeit",
"Favorite" : "Favorit",
"Are you sure you want to delete this subscription?" : "Bist Du sicher, dass Du das Abonnement löschen möchtest?",
"Error while removing the feed" : "Fehler beim Löschen des Feeds",
"You can only have 10 favorites" : "Du kannst nur max. 10 Favoriten haben",
"Add a podcast" : "Einen Podcast hinzufügen",
"Could not fetch subscriptions" : "Abonnements können nicht abgerufen werden",
"Find a podcast" : "Finde einen Podcast",
"Error loading feed" : "Fehler beim Laden des Feeds",
"Missing required app" : "Benötigte App fehlt",
"Install GPodder Sync" : "Installiere GPodder Sync",
"Pin some subscriptions to see their latest updates" : "Markiere Abonnements, um die neuesten Updates zu sehen",
"No favorites" : "Keine Favoriten",
"A browser extension conflict with RePod" : "Ein Browser-Plugin Problem mit RePod"
},
"nplurals=2; plural=n != 1;");

View File

@ -1,64 +0,0 @@
{ "translations": {
"RePod Subscriptions" : "RePod Abonnements",
"Podcast" : "Podcast",
"RePod" : "RePod",
"🔊 Browse, manage and listen to podcasts" : "🔊 Suchen, Verwalten und Anhören von Podcasts",
"## Features\n- 🔍 Browse and subscribe huge collection of podcasts\n- 🔊 Listen to episodes directly in Nextcloud\n- 🌐 Sync your activity with [AntennaPod](https://antennapod.org/) and [other apps](https://git.crystalyx.net/Xefir/repod#clients-supporting-sync-of-gpoddersync)\n- 📱 Mobile friendly interface\n- 📡 Import and export your subscriptions\n- ➡️ Full features comparison [here](https://git.crystalyx.net/Xefir/repod#comparaison-with-similar-apps-for-nextcloud)\n\n## Requirements\nYou need to have [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installed to use this app!" : "## Funktionen\n- 🔍 Durchsuchen und abonnieren einer großen Sammlung von Podcasts\n- 🔊 Episoden direkt in Nextcloud anhören\n- 🌐 Synchronisiere deine Aktivität mit [AntennaPod](https://antennapod.org/)\n- 📱 Handy-freundliche Schnittstelle\n- 📡 Importieren und Exportieren Ihrer Abonnements\n- ➡️ Vollständiger Funktionsvergleich [hier](https://git.crystalyx.net/Xefir/repod#comparaison-with-similar-apps-for-nextcloud)\n\n## Voraussetzungen\nDu musst [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installiert haben, um diese App zu benutzen!",
"Download" : "Herunterladen",
"Skip to {match}" : "Springe zu {match}",
"Add a RSS link" : "Einen RSS-Link hinzufügen",
"Subscribe" : "Abonnieren",
"Error while adding the feed" : "Fehler beim Hinzufügen des Feeds",
"Could not fetch search results" : "Suchergebnisse können nicht geladen werden",
"New podcasts" : "Neue Podcasts",
"Hot podcasts" : "Beliebte Podcasts",
"Could not fetch tops" : "Titel können nicht abgerufen werden",
"Copy feed" : "Feed kopieren",
"Link copied to the clipboard" : "Link in die Zwischenablage kopiert",
"Play" : "Abspielen",
"Stop" : "Stopp",
"Read" : "gehört",
"Open website" : "Webseite aufrufen",
"Select" : "Auswählen",
"Could not change the status of the episode" : "Kann den Status der Folge nicht ändern",
"Read all" : "Alles lesen",
"Unread all" : "Ungelesen",
"Select all" : "Alle auswählen",
"Unselect all" : "Alles abwählen",
"Could not fetch episodes" : "Folgen können nicht abgerufen werden",
"_%n episode selected_::_%n episodes selected_" : ["%n Folge ausgewählt","%n Folgen ausgewählt"],
"Rewind 10 seconds" : "10 Sekunden zurück",
"Pause" : "Pause",
"Fast forward 30 seconds" : "30 Sekunden vor",
"Mute" : "Stumm",
"Unmute" : "Stummschalten aufheben",
"Export subscriptions" : "Abonnements exportieren",
"Filtering episodes" : "Folgen filtern",
"Show all" : "Zeige alles",
"Listened" : "Gehört",
"Listening" : "Läuft",
"Unlistened" : "Nicht angehört",
"Import subscriptions" : "Importiere Abonnements",
"Import OPML file" : "Importiere OPML-Datei",
"Rate RePod ❤️" : "Bewerte RePod ❤️",
"Settings" : "Einstellungen",
"Sleep timer" : "Einschlaftimer",
"Minutes" : "Minuten",
"_%n min_::_%n mins_" : ["%n Minute","%n Minuten"],
"_%n sec_::_%n secs_" : ["%n sec","%n secs"],
"Playback speed" : "Wiedergabegeschwindigkeit",
"Favorite" : "Favorit",
"Are you sure you want to delete this subscription?" : "Bist Du sicher, dass Du das Abonnement löschen möchtest?",
"Error while removing the feed" : "Fehler beim Löschen des Feeds",
"You can only have 10 favorites" : "Du kannst nur max. 10 Favoriten haben",
"Add a podcast" : "Einen Podcast hinzufügen",
"Could not fetch subscriptions" : "Abonnements können nicht abgerufen werden",
"Find a podcast" : "Finde einen Podcast",
"Error loading feed" : "Fehler beim Laden des Feeds",
"Missing required app" : "Benötigte App fehlt",
"Install GPodder Sync" : "Installiere GPodder Sync",
"Pin some subscriptions to see their latest updates" : "Markiere Abonnements, um die neuesten Updates zu sehen",
"No favorites" : "Keine Favoriten",
"A browser extension conflict with RePod" : "Ein Browser-Plugin Problem mit RePod"
},"pluralForm" :"nplurals=2; plural=n != 1;"
}

View File

@ -1,66 +0,0 @@
OC.L10N.register(
"repod",
{
"RePod Subscriptions" : "اشتراک‌های ریپاد",
"Podcast" : "پادپخش",
"RePod" : "ریپاد",
"🔊 Browse, manage and listen to 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!" : "## ویژگی‌ها\n- 🔍 مرور و اشتراک در مجموعه بزرگ پادپخش‌ها\n- 🔊 گوش دادن به قسمت‌ها مستقیماً در نکست‌کلود\n- 🌐 همگام‌سازی فعالیت‌ها با [AntennaPod](https://antennapod.org/) و [برنامه‌های دیگر](https://git.crystalyx.net/Xefir/repod#clients-supporting-sync-of-gpoddersync)\n- 📱 رابط مناسب برای تلفن‌همراه\n- 📡 وارد کردن و صادر کردن اشتراک‌ها\n- ➡️ مقایسه کامل ویژگی‌ها در [اینجا](https://git.crystalyx.net/Xefir/repod#comparaison-with-similar-apps-for-nextcloud)\n\n## پیش‌نیازها\nبرای به‌کارگیری این برنامه، باید [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) نصب شده باشد!",
"Download" : "بارگیری",
"Skip to {match}" : "پرش به {match}",
"Add a RSS link" : "افزودن پیوند RSS",
"Subscribe" : "اشتراک کردن",
"Error while adding the feed" : "خطا در افزودن خوراک",
"Could not fetch search results" : "ناتوانی در دریافت یافته‌های جستجو",
"New podcasts" : "پادپخش‌های تازه",
"Hot podcasts" : "پادپخش‌های پرطرفدار",
"Could not fetch tops" : "ناتوانی در دریافت برترین‌ها",
"Copy feed" : "رونوشت خوراک",
"Link copied to the clipboard" : "پیوند به بریده‌دان رونویسی شد",
"Play" : "پخش",
"Stop" : "توقف",
"Read" : "خوانده‌شده",
"Open website" : "بازکردن وبگاه",
"Select" : "گزینش",
"Could not change the status of the episode" : "ناتوانی در تغییر وضعیت قسمت",
"Read all" : "خواندن همه",
"Unread all" : "ناخوانده کردن همه",
"Select all" : "گزینش همه",
"Unselect all" : "رد گزینش همه",
"Could not fetch episodes" : "ناتوانی در دریافت قسمت‌ها",
"_%n episode selected_::_%n episodes selected_" : ["%n قسمت گزینش شد","%n قسمت گزینش شدند"],
"Rewind 10 seconds" : "بازگرداندن ۱۰ ثانیه",
"Pause" : "مکث",
"Fast forward 30 seconds" : "پیش‌روی سریع ۳۰ ثانیه",
"Mute" : "بی‌صدا",
"Unmute" : "با صدا",
"Export subscriptions" : "صادر کردن اشتراک‌ها",
"Filtering episodes" : "پالایش قسمت‌ها",
"Show all" : "نمایش همه",
"Listened" : "گوش‌شده",
"Listening" : "در حال گوش دادن",
"Unlistened" : "شنیده نشده",
"Import subscriptions" : "وارد کردن اشتراک‌ها",
"Import OPML file" : "وارد کردن پرونده OPML",
"Rate RePod ❤️" : "امتیاز به ریپاد ❤️",
"Settings" : "تنظیمات",
"Sleep timer" : "زمان‌سنج خواب",
"Minutes" : "دقیقه",
"_%n min_::_%n mins_" : ["%n دقیقه","%n دقیقه"],
"_%n sec_::_%n secs_" : ["%n ثانیه","%n ثانیه"],
"Playback speed" : "سرعت پخش",
"Favorite" : "برگزیدن",
"Are you sure you want to delete this subscription?" : "آیا مطمئن هستید که می‌خواهید این اشتراک را پاک کنید؟",
"Error while removing the feed" : "خطا در پاک کردن خوراک",
"You can only have 10 favorites" : "فقط می‌توانید ۱۰ مورد برگزیده داشته باشید",
"Add a podcast" : "افزودن پادپخش",
"Could not fetch subscriptions" : "ناتوانی در دریافت اشتراک‌ها",
"Find a podcast" : "یافتن پادپخش",
"Error loading feed" : "خطا در بارگذاری خوراک",
"Missing required app" : "برنامه موردنیاز نیست",
"Install GPodder Sync" : "نصب GPodder Sync",
"Pin some subscriptions to see their latest updates" : "سنجاق کردن برخی اشتراک‌ها برای دیدن به‌روزرسانی‌های تازه",
"No favorites" : "بدون برگزیده",
"A browser extension conflict with RePod" : "ناسازگاری افزونه مرورگر با ریپاد"
},
"nplurals=2; plural=n > 1;");

View File

@ -1,64 +0,0 @@
{ "translations": {
"RePod Subscriptions" : "اشتراک‌های ریپاد",
"Podcast" : "پادپخش",
"RePod" : "ریپاد",
"🔊 Browse, manage and listen to 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!" : "## ویژگی‌ها\n- 🔍 مرور و اشتراک در مجموعه بزرگ پادپخش‌ها\n- 🔊 گوش دادن به قسمت‌ها مستقیماً در نکست‌کلود\n- 🌐 همگام‌سازی فعالیت‌ها با [AntennaPod](https://antennapod.org/) و [برنامه‌های دیگر](https://git.crystalyx.net/Xefir/repod#clients-supporting-sync-of-gpoddersync)\n- 📱 رابط مناسب برای تلفن‌همراه\n- 📡 وارد کردن و صادر کردن اشتراک‌ها\n- ➡️ مقایسه کامل ویژگی‌ها در [اینجا](https://git.crystalyx.net/Xefir/repod#comparaison-with-similar-apps-for-nextcloud)\n\n## پیش‌نیازها\nبرای به‌کارگیری این برنامه، باید [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) نصب شده باشد!",
"Download" : "بارگیری",
"Skip to {match}" : "پرش به {match}",
"Add a RSS link" : "افزودن پیوند RSS",
"Subscribe" : "اشتراک کردن",
"Error while adding the feed" : "خطا در افزودن خوراک",
"Could not fetch search results" : "ناتوانی در دریافت یافته‌های جستجو",
"New podcasts" : "پادپخش‌های تازه",
"Hot podcasts" : "پادپخش‌های پرطرفدار",
"Could not fetch tops" : "ناتوانی در دریافت برترین‌ها",
"Copy feed" : "رونوشت خوراک",
"Link copied to the clipboard" : "پیوند به بریده‌دان رونویسی شد",
"Play" : "پخش",
"Stop" : "توقف",
"Read" : "خوانده‌شده",
"Open website" : "بازکردن وبگاه",
"Select" : "گزینش",
"Could not change the status of the episode" : "ناتوانی در تغییر وضعیت قسمت",
"Read all" : "خواندن همه",
"Unread all" : "ناخوانده کردن همه",
"Select all" : "گزینش همه",
"Unselect all" : "رد گزینش همه",
"Could not fetch episodes" : "ناتوانی در دریافت قسمت‌ها",
"_%n episode selected_::_%n episodes selected_" : ["%n قسمت گزینش شد","%n قسمت گزینش شدند"],
"Rewind 10 seconds" : "بازگرداندن ۱۰ ثانیه",
"Pause" : "مکث",
"Fast forward 30 seconds" : "پیش‌روی سریع ۳۰ ثانیه",
"Mute" : "بی‌صدا",
"Unmute" : "با صدا",
"Export subscriptions" : "صادر کردن اشتراک‌ها",
"Filtering episodes" : "پالایش قسمت‌ها",
"Show all" : "نمایش همه",
"Listened" : "گوش‌شده",
"Listening" : "در حال گوش دادن",
"Unlistened" : "شنیده نشده",
"Import subscriptions" : "وارد کردن اشتراک‌ها",
"Import OPML file" : "وارد کردن پرونده OPML",
"Rate RePod ❤️" : "امتیاز به ریپاد ❤️",
"Settings" : "تنظیمات",
"Sleep timer" : "زمان‌سنج خواب",
"Minutes" : "دقیقه",
"_%n min_::_%n mins_" : ["%n دقیقه","%n دقیقه"],
"_%n sec_::_%n secs_" : ["%n ثانیه","%n ثانیه"],
"Playback speed" : "سرعت پخش",
"Favorite" : "برگزیدن",
"Are you sure you want to delete this subscription?" : "آیا مطمئن هستید که می‌خواهید این اشتراک را پاک کنید؟",
"Error while removing the feed" : "خطا در پاک کردن خوراک",
"You can only have 10 favorites" : "فقط می‌توانید ۱۰ مورد برگزیده داشته باشید",
"Add a podcast" : "افزودن پادپخش",
"Could not fetch subscriptions" : "ناتوانی در دریافت اشتراک‌ها",
"Find a podcast" : "یافتن پادپخش",
"Error loading feed" : "خطا در بارگذاری خوراک",
"Missing required app" : "برنامه موردنیاز نیست",
"Install GPodder Sync" : "نصب GPodder Sync",
"Pin some subscriptions to see their latest updates" : "سنجاق کردن برخی اشتراک‌ها برای دیدن به‌روزرسانی‌های تازه",
"No favorites" : "بدون برگزیده",
"A browser extension conflict with RePod" : "ناسازگاری افزونه مرورگر با ریپاد"
},"pluralForm" :"nplurals=2; plural=n > 1;"
}

View File

@ -5,9 +5,8 @@ OC.L10N.register(
"Podcast" : "Podcast",
"RePod" : "RePod",
"🔊 Browse, manage and listen to podcasts" : "🔊 Parcourir, gérer et écouter vos podcasts",
"## Features\n- 🔍 Browse and subscribe huge collection of podcasts\n- 🔊 Listen to episodes directly in Nextcloud\n- 🌐 Sync your activity with [AntennaPod](https://antennapod.org/) and [other apps](https://git.crystalyx.net/Xefir/repod#clients-supporting-sync-of-gpoddersync)\n- 📱 Mobile friendly interface\n- 📡 Import and export your subscriptions\n- ➡️ Full features comparison [here](https://git.crystalyx.net/Xefir/repod#comparaison-with-similar-apps-for-nextcloud)\n\n## Requirements\nYou need to have [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installed to use this app!" : "## Fonctionnalités\n- 🔍 Parcourir et s'abonner à une grande collections de podcasts\n- 🔊 Écouter vos épisodes directement sur Nextcloud\n- 🌐 Synchroniser son activité avec [AntennaPod](https://antennapod.org/) et d'autres [applications](https://git.crystalyx.net/Xefir/repod#clients-supporting-sync-of-gpoddersync)\n- 📱 Interface optimisée pour mobiles et ordinateurs\n- 📡 Import/export de ses abonnements\n- ➡️ Tableau récapitulatif complet des fonctionnalitées [ici](https://git.crystalyx.net/Xefir/repod#comparaison-with-similar-apps-for-nextcloud)\n\n## Pré-requis\nVous devez avoir [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installé pour utiliser cette application !",
"## Features\n- 🔍 Browse and subscribe huge collection of podcasts\n- 🔊 Listen to episodes directly in Nextcloud\n- 🌐 Sync your activity with [AntennaPod](https://antennapod.org/)\n\n## Requirements\nYou need to have [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installed to use this app!" : "## Fonctionnalités\n- 🔍 Parcourir et s'abonner à une grande collections de podcasts\n- 🔊 Écouter vos épisodes directement sur Nextcloud\n- 🌐 Synchroniser son activité avec [AntennaPod](https://antennapod.org/)\n\n## Pré-requis\nVous devez avoir [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installé pour utiliser cette application !",
"Download" : "Télécharger",
"Skip to {match}" : "Sauter à {match}",
"Add a RSS link" : "Ajouter un lien RSS",
"Subscribe" : "S'abonner",
"Error while adding the feed" : "Erreur lors de l'ajout du flux",
@ -16,24 +15,13 @@ OC.L10N.register(
"Hot podcasts" : "Tendances",
"Could not fetch tops" : "Impossible de récupérer les tops",
"Copy feed" : "Copier le flux",
"Link copied to the clipboard" : "Lien vers le flux copié dans le presse-papiers",
"Play" : "Lecture",
"Stop" : "Arrêter",
"Read" : "Marquer comme lu",
"Mark as read" : "Marquer comme lu",
"Mark as unread" : "Marquer comme non lu",
"Open website" : "Ouvrir le site web",
"Select" : "Sélectionner",
"Could not change the status of the episode" : "Impossible de changer le status de l'épisode",
"Read all" : "Marquer tout comme lu",
"Unread all" : "Marquer tout comme non lu",
"Select all" : "Sélectionner tout",
"Unselect all" : "Désélectionner tout",
"Could not fetch episodes" : "Impossible de récuprer les épisodes",
"_%n episode selected_::_%n episodes selected_" : ["%n épisode sélectionné","%n épisodes sélectionnés"],
"Rewind 10 seconds" : "Retour rapide de 10 secondes",
"Pause" : "Pause",
"Fast forward 30 seconds" : "Avance rapide de 30 secondes",
"Mute" : "Silencer",
"Unmute" : "Paroler",
"Could not change the status of the episode" : "Impossible de changer le status de l'épisode",
"Export subscriptions" : "Exporter les abonnements",
"Filtering episodes" : "Filtrage des épisodes",
"Show all" : "Montrer tout",
@ -43,24 +31,15 @@ OC.L10N.register(
"Import subscriptions" : "Importer les abonnements",
"Import OPML file" : "Importer un fichier OPML",
"Rate RePod ❤️" : "Donnez votre avis ❤️",
"Settings" : "Paramètres",
"Sleep timer" : "Minuteur",
"Minutes" : "Minutes",
"_%n min_::_%n mins_" : ["%n min","%n mins"],
"_%n sec_::_%n secs_" : ["%n sec","%n secs"],
"Playback speed" : "Vitesse de lecture",
"Favorite" : "Favori",
"Are you sure you want to delete this subscription?" : "Êtes-vous sûr de vouloir supprimer ce flux ?",
"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",
"Could not fetch subscriptions" : "Impossible de récupérer les flux",
"Find a podcast" : "Chercher un podcast",
"Error loading feed" : "Erreur lors du chargement du flux",
"Missing required app" : "Une application requise est manquante",
"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"
"Feed's link copied to the clipboard" : "Lien vers le flux copié dans le presse-papiers"
},
"nplurals=2; plural=n > 1;");
"");

View File

@ -3,9 +3,8 @@
"Podcast" : "Podcast",
"RePod" : "RePod",
"🔊 Browse, manage and listen to podcasts" : "🔊 Parcourir, gérer et écouter vos podcasts",
"## Features\n- 🔍 Browse and subscribe huge collection of podcasts\n- 🔊 Listen to episodes directly in Nextcloud\n- 🌐 Sync your activity with [AntennaPod](https://antennapod.org/) and [other apps](https://git.crystalyx.net/Xefir/repod#clients-supporting-sync-of-gpoddersync)\n- 📱 Mobile friendly interface\n- 📡 Import and export your subscriptions\n- ➡️ Full features comparison [here](https://git.crystalyx.net/Xefir/repod#comparaison-with-similar-apps-for-nextcloud)\n\n## Requirements\nYou need to have [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installed to use this app!" : "## Fonctionnalités\n- 🔍 Parcourir et s'abonner à une grande collections de podcasts\n- 🔊 Écouter vos épisodes directement sur Nextcloud\n- 🌐 Synchroniser son activité avec [AntennaPod](https://antennapod.org/) et d'autres [applications](https://git.crystalyx.net/Xefir/repod#clients-supporting-sync-of-gpoddersync)\n- 📱 Interface optimisée pour mobiles et ordinateurs\n- 📡 Import/export de ses abonnements\n- ➡️ Tableau récapitulatif complet des fonctionnalitées [ici](https://git.crystalyx.net/Xefir/repod#comparaison-with-similar-apps-for-nextcloud)\n\n## Pré-requis\nVous devez avoir [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installé pour utiliser cette application !",
"## Features\n- 🔍 Browse and subscribe huge collection of podcasts\n- 🔊 Listen to episodes directly in Nextcloud\n- 🌐 Sync your activity with [AntennaPod](https://antennapod.org/)\n\n## Requirements\nYou need to have [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installed to use this app!" : "## Fonctionnalités\n- 🔍 Parcourir et s'abonner à une grande collections de podcasts\n- 🔊 Écouter vos épisodes directement sur Nextcloud\n- 🌐 Synchroniser son activité avec [AntennaPod](https://antennapod.org/)\n\n## Pré-requis\nVous devez avoir [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installé pour utiliser cette application !",
"Download" : "Télécharger",
"Skip to {match}" : "Sauter à {match}",
"Add a RSS link" : "Ajouter un lien RSS",
"Subscribe" : "S'abonner",
"Error while adding the feed" : "Erreur lors de l'ajout du flux",
@ -14,24 +13,13 @@
"Hot podcasts" : "Tendances",
"Could not fetch tops" : "Impossible de récupérer les tops",
"Copy feed" : "Copier le flux",
"Link copied to the clipboard" : "Lien vers le flux copié dans le presse-papiers",
"Play" : "Lecture",
"Stop" : "Arrêter",
"Read" : "Marquer comme lu",
"Mark as read" : "Marquer comme lu",
"Mark as unread" : "Marquer comme non lu",
"Open website" : "Ouvrir le site web",
"Select" : "Sélectionner",
"Could not change the status of the episode" : "Impossible de changer le status de l'épisode",
"Read all" : "Marquer tout comme lu",
"Unread all" : "Marquer tout comme non lu",
"Select all" : "Sélectionner tout",
"Unselect all" : "Désélectionner tout",
"Could not fetch episodes" : "Impossible de récuprer les épisodes",
"_%n episode selected_::_%n episodes selected_" : ["%n épisode sélectionné","%n épisodes sélectionnés"],
"Rewind 10 seconds" : "Retour rapide de 10 secondes",
"Pause" : "Pause",
"Fast forward 30 seconds" : "Avance rapide de 30 secondes",
"Mute" : "Silencer",
"Unmute" : "Paroler",
"Could not change the status of the episode" : "Impossible de changer le status de l'épisode",
"Export subscriptions" : "Exporter les abonnements",
"Filtering episodes" : "Filtrage des épisodes",
"Show all" : "Montrer tout",
@ -41,24 +29,15 @@
"Import subscriptions" : "Importer les abonnements",
"Import OPML file" : "Importer un fichier OPML",
"Rate RePod ❤️" : "Donnez votre avis ❤️",
"Settings" : "Paramètres",
"Sleep timer" : "Minuteur",
"Minutes" : "Minutes",
"_%n min_::_%n mins_" : ["%n min","%n mins"],
"_%n sec_::_%n secs_" : ["%n sec","%n secs"],
"Playback speed" : "Vitesse de lecture",
"Favorite" : "Favori",
"Are you sure you want to delete this subscription?" : "Êtes-vous sûr de vouloir supprimer ce flux ?",
"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",
"Could not fetch subscriptions" : "Impossible de récupérer les flux",
"Find a podcast" : "Chercher un podcast",
"Error loading feed" : "Erreur lors du chargement du flux",
"Missing required app" : "Une application requise est manquante",
"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" :"nplurals=2; plural=n > 1;"
"Feed's link copied to the clipboard" : "Lien vers le flux copié dans le presse-papiers"
},"pluralForm" :""
}

View File

@ -1,66 +0,0 @@
OC.L10N.register(
"repod",
{
"RePod Subscriptions" : "Subskrypcje RePod",
"Podcast" : "Podkast",
"RePod" : "RePod",
"🔊 Browse, manage and listen to podcasts" : "🔊 Przeglądanie, zarządzanie i słuchanie podkastów",
"## 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!" : "## Funkcje\n- 🔍 Przeglądaj i subskrybuj ogromną kolekcję podcastów\n- 🔊 Słuchaj odcinków bezpośrednio w Nextcloud\n- 🌐 Synchronizuj swoją aktywność z [AntennaPod](https://antennapod.org/) i [innymi aplikacjami](https://git.crystalyx.net/Xefir/repod#clients-supporting-sync-of-gpoddersync)\n- 📱 Interfejs dla urządzeń mobilnych\n- 📡 Importuj i eksportuj swoje subskrypcje\n- ➡️ Pełne porównanie funkcji [tutaj](https://git.crystalyx.net/Xefir/repod#comparaison-with-similar-apps-for-nextcloud)\n\n## Wymagania\nAby korzystać z tej aplikacji, musisz mieć zainstalowany [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync)!",
"Download" : "Pobierz",
"Skip to {match}" : "Skocz do {match}",
"Add a RSS link" : "Dodaj RSS z linka",
"Subscribe" : "Subskrybuj",
"Error while adding the feed" : "Błąd podczas dodawania kanału",
"Could not fetch search results" : "Nie można pobrać wyników wyszukiwania",
"New podcasts" : "Nowe podkasty",
"Hot podcasts" : "Gorące podkasty",
"Could not fetch tops" : "Nie można pobrać poleceń",
"Copy feed" : "Kopiuj kanał",
"Link copied to the clipboard" : "Link skopiowany do schowka",
"Play" : "Odtwarzaj",
"Stop" : "Stop",
"Read" : "Czytaj",
"Open website" : "Otwórz stronę",
"Select" : "Wybierz",
"Could not change the status of the episode" : "Nie można zmienić stanu odcinka",
"Read all" : "Czytaj wszystko",
"Unread all" : "Cofnij czytaj wszystko",
"Select all" : "Zaznacz wszystko",
"Unselect all" : "Odznacz wszystko",
"Could not fetch episodes" : "Nie można pobrać odcinków",
"_%n episode selected_::_%n episodes selected_" : ["%n wybrany odcinek","%n wybrane odcinki","%n wybranych odcinków"],
"Rewind 10 seconds" : "Przewiń 10 sekund",
"Pause" : "Pauza",
"Fast forward 30 seconds" : "Do przodu 30 sekund",
"Mute" : "Wycisz",
"Unmute" : "Wyłącz wyciszenie",
"Export subscriptions" : "Eksportuj subskrypcje",
"Filtering episodes" : "Filtr odcinków",
"Show all" : "Pokaż wszystko",
"Listened" : "Słuchany",
"Listening" : "Słuchanie",
"Unlistened" : "Niewysłuchane",
"Import subscriptions" : "Import subskrypcji",
"Import OPML file" : "Import pliku OPML",
"Rate RePod ❤️" : "Oceń RePod ❤️",
"Settings" : "Ustawienia",
"Sleep timer" : "Wyłącznik czasowy",
"Minutes" : "Minuty",
"_%n min_::_%n mins_" : ["%n minuta","%n minuty","%n minut"],
"_%n sec_::_%n secs_" : ["%n sekunda","%n sekundy","%n sekund"],
"Playback speed" : "Prędkość odtwarzania",
"Favorite" : "Ulubione",
"Are you sure you want to delete this subscription?" : "Czy na pewno chcesz usunąć tę subskrypcję?",
"Error while removing the feed" : "Błąd podczas usuwania kanału",
"You can only have 10 favorites" : "Możesz mieć tylko 10 ulubionych",
"Add a podcast" : "Dodaj podkast",
"Could not fetch subscriptions" : "Nie można pobrać subskrypcji",
"Find a podcast" : "Znajdź podkast",
"Error loading feed" : "Błąd ładowania kanału",
"Missing required app" : "Brak wymaganej aplikacji",
"Install GPodder Sync" : "Zainstaluj GPodder Sync",
"Pin some subscriptions to see their latest updates" : "Przypnij niektóre subskrypcje, by zobaczyć aktualizacje",
"No favorites" : "Brak ulubionych",
"A browser extension conflict with RePod" : "Konflikt rozszerzenia przeglądarki z RePod"
},
"nplurals=3; plural=n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;");

View File

@ -1,64 +0,0 @@
{ "translations": {
"RePod Subscriptions" : "Subskrypcje RePod",
"Podcast" : "Podkast",
"RePod" : "RePod",
"🔊 Browse, manage and listen to podcasts" : "🔊 Przeglądanie, zarządzanie i słuchanie podkastów",
"## 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!" : "## Funkcje\n- 🔍 Przeglądaj i subskrybuj ogromną kolekcję podcastów\n- 🔊 Słuchaj odcinków bezpośrednio w Nextcloud\n- 🌐 Synchronizuj swoją aktywność z [AntennaPod](https://antennapod.org/) i [innymi aplikacjami](https://git.crystalyx.net/Xefir/repod#clients-supporting-sync-of-gpoddersync)\n- 📱 Interfejs dla urządzeń mobilnych\n- 📡 Importuj i eksportuj swoje subskrypcje\n- ➡️ Pełne porównanie funkcji [tutaj](https://git.crystalyx.net/Xefir/repod#comparaison-with-similar-apps-for-nextcloud)\n\n## Wymagania\nAby korzystać z tej aplikacji, musisz mieć zainstalowany [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync)!",
"Download" : "Pobierz",
"Skip to {match}" : "Skocz do {match}",
"Add a RSS link" : "Dodaj RSS z linka",
"Subscribe" : "Subskrybuj",
"Error while adding the feed" : "Błąd podczas dodawania kanału",
"Could not fetch search results" : "Nie można pobrać wyników wyszukiwania",
"New podcasts" : "Nowe podkasty",
"Hot podcasts" : "Gorące podkasty",
"Could not fetch tops" : "Nie można pobrać poleceń",
"Copy feed" : "Kopiuj kanał",
"Link copied to the clipboard" : "Link skopiowany do schowka",
"Play" : "Odtwarzaj",
"Stop" : "Stop",
"Read" : "Czytaj",
"Open website" : "Otwórz stronę",
"Select" : "Wybierz",
"Could not change the status of the episode" : "Nie można zmienić stanu odcinka",
"Read all" : "Czytaj wszystko",
"Unread all" : "Cofnij czytaj wszystko",
"Select all" : "Zaznacz wszystko",
"Unselect all" : "Odznacz wszystko",
"Could not fetch episodes" : "Nie można pobrać odcinków",
"_%n episode selected_::_%n episodes selected_" : ["%n wybrany odcinek","%n wybrane odcinki","%n wybranych odcinków"],
"Rewind 10 seconds" : "Przewiń 10 sekund",
"Pause" : "Pauza",
"Fast forward 30 seconds" : "Do przodu 30 sekund",
"Mute" : "Wycisz",
"Unmute" : "Wyłącz wyciszenie",
"Export subscriptions" : "Eksportuj subskrypcje",
"Filtering episodes" : "Filtr odcinków",
"Show all" : "Pokaż wszystko",
"Listened" : "Słuchany",
"Listening" : "Słuchanie",
"Unlistened" : "Niewysłuchane",
"Import subscriptions" : "Import subskrypcji",
"Import OPML file" : "Import pliku OPML",
"Rate RePod ❤️" : "Oceń RePod ❤️",
"Settings" : "Ustawienia",
"Sleep timer" : "Wyłącznik czasowy",
"Minutes" : "Minuty",
"_%n min_::_%n mins_" : ["%n minuta","%n minuty","%n minut"],
"_%n sec_::_%n secs_" : ["%n sekunda","%n sekundy","%n sekund"],
"Playback speed" : "Prędkość odtwarzania",
"Favorite" : "Ulubione",
"Are you sure you want to delete this subscription?" : "Czy na pewno chcesz usunąć tę subskrypcję?",
"Error while removing the feed" : "Błąd podczas usuwania kanału",
"You can only have 10 favorites" : "Możesz mieć tylko 10 ulubionych",
"Add a podcast" : "Dodaj podkast",
"Could not fetch subscriptions" : "Nie można pobrać subskrypcji",
"Find a podcast" : "Znajdź podkast",
"Error loading feed" : "Błąd ładowania kanału",
"Missing required app" : "Brak wymaganej aplikacji",
"Install GPodder Sync" : "Zainstaluj GPodder Sync",
"Pin some subscriptions to see their latest updates" : "Przypnij niektóre subskrypcje, by zobaczyć aktualizacje",
"No favorites" : "Brak ulubionych",
"A browser extension conflict with RePod" : "Konflikt rozszerzenia przeglądarki z RePod"
},"pluralForm" :"nplurals=3; plural=n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;"
}

View File

@ -16,7 +16,6 @@ use OCP\AppFramework\Services\IInitialState;
class Application extends App implements IBootstrap
{
public const APP_ID = 'repod';
private const GPODDERSYNC_ID = 'gpoddersync';
public function __construct() {
@ -24,6 +23,7 @@ class Application extends App implements IBootstrap
}
public function boot(IBootContext $context): void {
/** @psalm-suppress DeprecatedInterface */
$appContainer = $context->getAppContainer();
/** @var IAppManager $appManager */
@ -32,15 +32,11 @@ class Application extends App implements IBootstrap
/** @var IInitialState $initialState */
$initialState = $appContainer->get(IInitialState::class);
if (null === $appManager->getAppInfo(self::GPODDERSYNC_ID)) {
$appManager->disableApp(self::GPODDERSYNC_ID);
}
$gpoddersync = $appManager->isEnabledForUser(self::GPODDERSYNC_ID);
if (!$gpoddersync) {
try {
$appManager->enableApp(self::GPODDERSYNC_ID);
} catch (AppPathNotFoundException) {
} catch (AppPathNotFoundException $e) {
}
}

View File

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace OCA\RePod\Controller;
use OCA\GPodderSync\Core\EpisodeAction\EpisodeAction;
use OCA\GPodderSync\Db\EpisodeAction\EpisodeActionRepository;
use OCA\RePod\AppInfo\Application;
use OCA\RePod\Core\EpisodeAction\EpisodeActionExtraData;
@ -12,55 +11,41 @@ use OCA\RePod\Core\EpisodeAction\EpisodeActionReader;
use OCA\RePod\Service\UserService;
use OCP\AppFramework\Controller;
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\Response;
use OCP\Http\Client\IClientService;
use OCP\IRequest;
/**
* @phpstan-import-type EpisodeActionType from EpisodeAction
*/
class EpisodesController extends Controller
{
public function __construct(
IRequest $request,
private readonly EpisodeActionReader $episodeActionReader,
private readonly EpisodeActionRepository $episodeActionRepository,
private readonly IClientService $clientService,
private readonly UserService $userService
private EpisodeActionReader $episodeActionReader,
private EpisodeActionRepository $episodeActionRepository,
private IClientService $clientService,
private UserService $userService
) {
parent::__construct(Application::APP_ID, $request);
}
/**
* @return JSONResponse<Http::STATUS_*, array<int, EpisodeActionExtraData>, array{}>
* @NoAdminRequired
* @NoCSRFRequired
*/
#[NoAdminRequired]
#[NoCSRFRequired]
#[FrontpageRoute(verb: 'GET', url: '/episodes/list')]
public function list(string $url): Response {
public function list(string $url): JSONResponse {
$client = $this->clientService->newClient();
$feed = $client->get($url);
$episodes = $this->episodeActionReader->parseRssXml((string) $feed->getBody());
usort($episodes, fn (EpisodeActionExtraData $a, EpisodeActionExtraData $b): int => $b->getPubDate() <=> $a->getPubDate());
$episodes = array_values(array_intersect_key($episodes, array_unique(array_map(fn (EpisodeActionExtraData $episode): string => $episode->getGuid(), $episodes))));
usort($episodes, fn (EpisodeActionExtraData $a, EpisodeActionExtraData $b) => $b->getPubDate() <=> $a->getPubDate());
$episodes = array_values(array_intersect_key($episodes, array_unique(array_map(fn (EpisodeActionExtraData $episode) => $episode->getGuid(), $episodes))));
/** @var Http::STATUS_* $returnStatusCode */
$returnStatusCode = $feed->getStatusCode();
return new JSONResponse($episodes, $returnStatusCode);
return new JSONResponse($episodes, $feed->getStatusCode());
}
/**
* @phpstan-ignore missingType.generics
* @NoAdminRequired
* @NoCSRFRequired
*/
#[NoAdminRequired]
#[NoCSRFRequired]
#[FrontpageRoute(verb: 'GET', url: '/episodes/action')]
public function action(string $url): Response {
public function action(string $url): JSONResponse {
$action = $this->episodeActionRepository->findByEpisodeUrl($url, $this->userService->getUserUID());
if ($action) {

View File

@ -10,10 +10,6 @@ use OCA\GPodderSync\Core\SubscriptionChange\SubscriptionChangeSaver;
use OCA\RePod\AppInfo\Application;
use OCA\RePod\Service\UserService;
use OCP\AppFramework\Controller;
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\DataDownloadResponse;
use OCP\AppFramework\Http\Response;
use OCP\IL10N;
@ -23,22 +19,20 @@ class OpmlController extends Controller
{
public function __construct(
IRequest $request,
private readonly IL10N $l10n,
private readonly PodcastDataReader $podcastDataReader,
private readonly PodcastMetricsReader $podcastMetricsReader,
private readonly SubscriptionChangeSaver $subscriptionChangeSaver,
private readonly UserService $userService
private IL10N $l10n,
private PodcastDataReader $podcastDataReader,
private PodcastMetricsReader $podcastMetricsReader,
private SubscriptionChangeSaver $subscriptionChangeSaver,
private UserService $userService
) {
parent::__construct(Application::APP_ID, $request);
}
/**
* @return DataDownloadResponse<Http::STATUS_OK, 'application/xml', array{}>
* @NoAdminRequired
* @NoCSRFRequired
*/
#[NoAdminRequired]
#[NoCSRFRequired]
#[FrontpageRoute(verb: 'GET', url: '/opml/export')]
public function export(): Response {
public function export(): DataDownloadResponse {
// https://github.com/AntennaPod/AntennaPod/blob/master/core/src/main/java/de/danoeh/antennapod/core/export/opml/OpmlWriter.java
$xml = new \SimpleXMLElement('<opml/>', namespaceOrPrefix: 'http://xmlpull.org/v1/doc/features.html#indent-output');
$xml->addAttribute('version', '2.0');
@ -59,7 +53,7 @@ class OpmlController extends Controller
foreach ($subscriptions as $subscription) {
try {
$podcast = $this->podcastDataReader->getCachedOrFetchPodcastData($subscription->getUrl(), $this->userService->getUserUID());
} catch (\Exception) {
} catch (\Exception $e) {
continue;
}
@ -85,34 +79,28 @@ 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');
}
/**
* @return Response<Http::STATUS_OK, array{}>
* @NoAdminRequired
* @NoCSRFRequired
*/
#[NoAdminRequired]
#[NoCSRFRequired]
#[FrontpageRoute(verb: 'POST', url: '/opml/import')]
public function import(): Response {
$file = $this->request->getUploadedFile('import');
if ($file) {
$fileContent = file_get_contents((string) $file['tmp_name']);
$xml = new \SimpleXMLElement(file_get_contents((string) $file['tmp_name']));
if (is_string($fileContent)) {
$xml = new \SimpleXMLElement($fileContent);
/** @var \SimpleXMLElement[] $outlines */
$outlines = $xml->body->children();
/** @var \SimpleXMLElement[] $outlines */
$outlines = $xml->body->children();
$toSubscribe = [];
foreach ($outlines as $outline) {
$toSubscribe[] = (string) $outline['xmlUrl'];
}
$this->subscriptionChangeSaver->saveSubscriptionChanges($toSubscribe, [], $this->userService->getUserUID());
$toSubscribe = [];
foreach ($outlines as $outline) {
$toSubscribe[] = (string) $outline['xmlUrl'];
}
$this->subscriptionChangeSaver->saveSubscriptionChanges($toSubscribe, [], $this->userService->getUserUID());
}
return new Response();

View File

@ -6,30 +6,19 @@ namespace OCA\RePod\Controller;
use OCA\RePod\AppInfo\Application;
use OCP\AppFramework\Controller;
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\ContentSecurityPolicy;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\IRequest;
use OCP\Util;
class PageController extends Controller
{
public function __construct(
IRequest $request
) {
parent::__construct(Application::APP_ID, $request);
}
/**
* @return TemplateResponse<Http::STATUS_OK, array{}>
* @NoAdminRequired
* @NoCSRFRequired
*/
#[NoAdminRequired]
#[NoCSRFRequired]
#[FrontpageRoute(verb: 'GET', url: '/')]
public function index(): Response {
public function index(): TemplateResponse {
Util::addScript(Application::APP_ID, 'main');
$csp = new ContentSecurityPolicy();
$csp->addAllowedImageDomain('*');
$csp->addAllowedMediaDomain('*');
@ -39,24 +28,4 @@ class PageController extends Controller
return $response;
}
/**
* @return TemplateResponse<Http::STATUS_OK, array{}>
*/
#[NoAdminRequired]
#[NoCSRFRequired]
#[FrontpageRoute(verb: 'GET', url: '/discover')]
public function discover(): Response {
return $this->index();
}
/**
* @return TemplateResponse<Http::STATUS_OK, array{}>
*/
#[NoAdminRequired]
#[NoCSRFRequired]
#[FrontpageRoute(verb: 'GET', url: '/feed/{path}', requirements: ['path' => '.+'])]
public function feed(): Response {
return $this->index();
}
}

View File

@ -8,41 +8,29 @@ use OCA\GPodderSync\Core\PodcastData\PodcastData;
use OCA\GPodderSync\Core\PodcastData\PodcastDataReader;
use OCA\RePod\AppInfo\Application;
use OCP\AppFramework\Controller;
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\Response;
use OCP\Http\Client\IClientService;
use OCP\ICacheFactory;
use OCP\IRequest;
class PodcastController extends Controller
{
public function __construct(
IRequest $request,
private readonly ICacheFactory $cacheFactory,
private readonly IClientService $clientService,
private readonly PodcastDataReader $podcastDataReader
private IClientService $clientService,
private PodcastDataReader $podcastDataReader
) {
parent::__construct(Application::APP_ID, $request);
}
/**
* @phpstan-ignore missingType.generics
* @NoAdminRequired
* @NoCSRFRequired
*/
#[NoAdminRequired]
#[NoCSRFRequired]
#[FrontpageRoute(verb: 'GET', url: '/podcast')]
public function index(string $url): Response {
$podcast = null;
if ($this->cacheFactory->isLocalCacheAvailable()) {
try {
$podcast = $this->podcastDataReader->tryGetCachedPodcastData($url);
} catch (\Exception) {
}
public function index(string $url): JSONResponse {
try {
$podcast = $this->podcastDataReader->tryGetCachedPodcastData($url);
} catch (\Exception $e) {
$podcast = null;
}
if ($podcast) {
@ -53,16 +41,11 @@ class PodcastController extends Controller
$feed = $client->get($url);
$podcast = PodcastData::parseRssXml((string) $feed->getBody());
if ($this->cacheFactory->isLocalCacheAvailable()) {
try {
$this->podcastDataReader->trySetCachedPodcastData($url, $podcast);
} catch (\Exception) {
}
try {
$this->podcastDataReader->trySetCachedPodcastData($url, $podcast);
} catch (\Exception $e) {
}
/** @var Http::STATUS_* $returnStatusCode */
$returnStatusCode = $feed->getStatusCode();
return new JSONResponse($podcast, $returnStatusCode);
return new JSONResponse($podcast, $feed->getStatusCode());
}
}

View File

@ -4,34 +4,26 @@ declare(strict_types=1);
namespace OCA\RePod\Controller;
use OCA\GPodderSync\Core\PodcastData\PodcastData;
use OCA\RePod\AppInfo\Application;
use OCA\RePod\Service\MultiPodService;
use OCP\AppFramework\Controller;
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\Response;
use OCP\IRequest;
class SearchController extends Controller
{
public function __construct(
IRequest $request,
private readonly MultiPodService $multiPodService
private MultiPodService $multiPodService
) {
parent::__construct(Application::APP_ID, $request);
}
/**
* @return JSONResponse<Http::STATUS_OK, PodcastData[], array{}>
* @NoAdminRequired
* @NoCSRFRequired
*/
#[NoAdminRequired]
#[NoCSRFRequired]
#[FrontpageRoute(verb: 'GET', url: '/search')]
public function index(string $q): Response {
public function index(string $q): JSONResponse {
return new JSONResponse($this->multiPodService->search($q));
}
}

View File

@ -4,44 +4,34 @@ declare(strict_types=1);
namespace OCA\RePod\Controller;
use OCA\GPodderSync\Core\PodcastData\PodcastData;
use OCA\RePod\AppInfo\Application;
use OCA\RePod\Service\FyydService;
use OCP\AppFramework\Controller;
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\Response;
use OCP\IRequest;
class ToplistController extends Controller
{
public function __construct(
IRequest $request,
private readonly FyydService $fyydService
private FyydService $fyydService
) {
parent::__construct(Application::APP_ID, $request);
}
/**
* @return JSONResponse<Http::STATUS_OK, PodcastData[], array{}>
* @NoAdminRequired
* @NoCSRFRequired
*/
#[NoAdminRequired]
#[NoCSRFRequired]
#[FrontpageRoute(verb: 'GET', url: '/toplist/hot')]
public function hot(): Response {
public function hot(): JSONResponse {
return new JSONResponse($this->fyydService->hot());
}
/**
* @return JSONResponse<Http::STATUS_OK, PodcastData[], array{}>
* @NoAdminRequired
* @NoCSRFRequired
*/
#[NoAdminRequired]
#[NoCSRFRequired]
#[FrontpageRoute(verb: 'GET', url: '/toplist/new')]
public function new(): Response {
public function new(): JSONResponse {
return new JSONResponse($this->fyydService->latest());
}
}

View File

@ -10,15 +10,14 @@ use OCA\GPodderSync\Core\EpisodeAction\EpisodeAction;
* Base: https://github.com/pbek/nextcloud-nextpod/blob/main/lib/Core/EpisodeAction/EpisodeActionExtraData.php.
* Specs: https://github.com/Podcast-Standards-Project/PSP-1-Podcast-RSS-Specification/blob/main/README.md#required-item-elements.
*
* @phpstan-import-type EpisodeActionType from EpisodeAction
* @psalm-import-type EpisodeActionType from EpisodeAction
*
* @phpstan-type EpisodeActionExtraDataType = array{
* @psalm-type EpisodeActionExtraDataType = array{
* title: string,
* url: ?string,
* name: string,
* link: ?string,
* image: ?string,
* author: ?string,
* description: ?string,
* fetchedAtUnix: int,
* guid: string,
@ -29,23 +28,22 @@ use OCA\GPodderSync\Core\EpisodeAction\EpisodeAction;
* action: ?EpisodeActionType
* }
*/
class EpisodeActionExtraData implements \JsonSerializable, \Stringable
class EpisodeActionExtraData implements \JsonSerializable
{
public function __construct(
private readonly string $title,
private readonly ?string $url,
private readonly string $name,
private readonly ?string $link,
private readonly ?string $image,
private readonly ?string $author,
private readonly ?string $description,
private readonly int $fetchedAtUnix,
private readonly string $guid,
private readonly ?string $type,
private readonly ?int $size,
private readonly ?\DateTime $pubDate,
private readonly ?string $duration,
private readonly ?EpisodeAction $action
private string $title,
private ?string $url,
private string $name,
private ?string $link,
private ?string $image,
private ?string $description,
private int $fetchedAtUnix,
private string $guid,
private ?string $type,
private ?int $size,
private ?\DateTime $pubDate,
private ?string $duration,
private ?EpisodeAction $action
) {}
public function __toString(): string {
@ -72,10 +70,6 @@ class EpisodeActionExtraData implements \JsonSerializable, \Stringable
return $this->image;
}
public function getAuthor(): ?string {
return $this->author;
}
public function getDescription(): ?string {
return $this->description;
}
@ -119,7 +113,6 @@ class EpisodeActionExtraData implements \JsonSerializable, \Stringable
'name' => $this->name,
'link' => $this->link,
'image' => $this->image,
'author' => $this->author,
'description' => $this->description,
'fetchedAtUnix' => $this->fetchedAtUnix,
'guid' => $this->guid,
@ -127,7 +120,7 @@ class EpisodeActionExtraData implements \JsonSerializable, \Stringable
'size' => $this->size,
'pubDate' => $this->pubDate,
'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
{
public function __construct(
private readonly EpisodeActionRepository $episodeActionRepository,
private readonly UserService $userService
private EpisodeActionRepository $episodeActionRepository,
private UserService $userService
) {}
/**
@ -20,8 +20,7 @@ class EpisodeActionReader extends CoreEpisodeActionReader
* Specs : https://github.com/Podcast-Standards-Project/PSP-1-Podcast-RSS-Specification/blob/main/README.md.
*
* @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 {
$episodes = [];
@ -56,24 +55,22 @@ class EpisodeActionReader extends CoreEpisodeActionReader
$link = $this->stringOrNull($item->link);
// Get episode image
if (isset($iTunesItemChildren)) {
$image = $this->stringOrNull($item->image->url);
if (!isset($image) && isset($iTunesItemChildren)) {
$imageAttributes = $iTunesItemChildren->image->attributes();
$image = $this->stringOrNull(isset($imageAttributes) ? (string) $imageAttributes->href : '');
}
if (!isset($image)) {
$image = $this->stringOrNull($channel->image->url);
}
if (!isset($image) && isset($iTunesChannelChildren)) {
$imageAttributes = $iTunesChannelChildren->image->attributes();
$image = $this->stringOrNull(isset($imageAttributes) ? (string) $imageAttributes->href : '');
}
if (!isset($image)) {
$image = $this->stringOrNull($item->image->url);
}
if (!isset($image)) {
$image = $this->stringOrNull($channel->image->url);
}
if (!isset($image)) {
preg_match('/<itunes:image\s+href="([^"]+)"/', $xmlString, $matches);
if (count($matches) > 1) {
@ -81,13 +78,6 @@ class EpisodeActionReader extends CoreEpisodeActionReader
}
}
// Get episode author
if (isset($iTunesItemChildren)) {
$author = $this->stringOrNull($iTunesItemChildren->author);
} else {
$author = $this->stringOrNull($item->author);
}
// Get episode description
$itemContent = $item->children('content', true);
if (isset($itemContent)) {
@ -100,6 +90,9 @@ class EpisodeActionReader extends CoreEpisodeActionReader
$description = $this->stringOrNull($iTunesItemChildren->summary);
}
// Remove tags
$description = strip_tags(str_replace(['<br>', '<br/>', '<br />'], "\n", $description ?? ''));
// Get episode duration
if (isset($iTunesItemChildren)) {
$duration = $this->stringOrNull($iTunesItemChildren->duration);
@ -112,7 +105,7 @@ class EpisodeActionReader extends CoreEpisodeActionReader
if (isset($pubDate)) {
try {
$pubDate = new \DateTime($pubDate);
} catch (\Exception) {
} catch (\Exception $e) {
$pubDate = null;
}
}
@ -123,7 +116,6 @@ class EpisodeActionReader extends CoreEpisodeActionReader
$name,
$link,
$image,
$author,
$description,
$fetchedAtUnix ?? (new \DateTime())->getTimestamp(),
$guid,
@ -142,6 +134,7 @@ class EpisodeActionReader extends CoreEpisodeActionReader
* @param null|\SimpleXMLElement|string $value
*/
private function stringOrNull($value): ?string {
/** @psalm-suppress RiskyTruthyFalsyComparison */
if (!empty($value)) {
return (string) $value;
}

View File

@ -13,9 +13,9 @@ class FyydService implements IPodProvider
private const BASE_URL = 'https://api.fyyd.de/0.2/';
public function __construct(
private readonly IClientService $clientService,
private readonly LoggerInterface $logger,
private readonly UserService $userService
private IClientService $clientService,
private LoggerInterface $logger,
private UserService $userService
) {}
public function search(string $value): array {
@ -35,14 +35,13 @@ class FyydService implements IPodProvider
/** @var string[] $feed */
foreach ($json['data'] as $feed) {
if ($feed['title']) {
$time = strtotime($feed['lastpub']);
$podcasts[] = new PodcastData(
$feed['title'],
$feed['author'],
$feed['xmlURL'],
$feed['description'],
$feed['imgURL'],
is_int($time) ? $time : time()
strtotime($feed['lastpub'])
);
}
}
@ -64,14 +63,13 @@ class FyydService implements IPodProvider
/** @var string[] $feed */
foreach ($podcastJson['data'] as $feed) {
if ($feed['title']) {
$time = strtotime($feed['lastpub']);
$podcasts[] = new PodcastData(
$feed['title'],
$feed['author'],
$feed['xmlURL'],
$feed['description'],
$feed['imgURL'],
is_int($time) ? $time : time()
strtotime($feed['lastpub'])
);
}
}
@ -96,8 +94,8 @@ class FyydService implements IPodProvider
if (array_key_exists('data', $langJson) && is_array($langJson['data'])) {
$language = in_array($userLang, $langJson['data']) ? $userLang : 'en';
}
} catch (\Exception $exception) {
$this->logger->error($exception->getMessage(), $exception->getTrace());
} catch (\Exception $e) {
$this->logger->error($e->getMessage(), $e->getTrace());
}
$podcastClient = $this->clientService->newClient();
@ -113,14 +111,13 @@ class FyydService implements IPodProvider
/** @var string[] $feed */
foreach ($postCastJson['data'] as $feed) {
if ($feed['title']) {
$time = strtotime($feed['lastpub']);
$podcasts[] = new PodcastData(
$feed['title'],
$feed['author'],
$feed['xmlURL'],
$feed['description'],
$feed['imgURL'],
is_int($time) ? $time : time()
strtotime($feed['lastpub'])
);
}
}

View File

@ -12,8 +12,8 @@ class ItunesService implements IPodProvider
private const BASE_URL = 'https://itunes.apple.com/';
public function __construct(
private readonly IClientService $clientService,
private readonly UserService $userService
private IClientService $clientService,
private UserService $userService
) {}
public function search(string $value): array {
@ -32,14 +32,13 @@ class ItunesService implements IPodProvider
if (array_key_exists('results', $json) && is_array($json['results'])) {
/** @var string[] $feed */
foreach ($json['results'] as $feed) {
$time = strtotime($feed['releaseDate']);
$podcasts[] = new PodcastData(
$feed['trackName'],
$feed['artistName'],
$feed['feedUrl'],
$feed['primaryGenreName'],
$feed['artworkUrl600'],
is_int($time) ? $time : time()
strtotime($feed['releaseDate'])
);
}
}

View File

@ -17,7 +17,7 @@ class MultiPodService implements IPodProvider
public function __construct(
FyydService $fyydService,
ItunesService $itunesService,
private readonly LoggerInterface $logger
private LoggerInterface $logger
) {
$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(
array_intersect_key(
@ -44,7 +44,7 @@ class MultiPodService implements IPodProvider
array_unique(
array_map(
fn (PodcastData $feed) => $feed->getLink(),
array_filter($podcasts, fn (PodcastData $feed): bool => null !== $feed->getLink())
array_filter($podcasts, fn (PodcastData $feed) => $feed->getLink())
)
)
)

View File

@ -16,9 +16,9 @@ use OCP\Search\SearchResultEntry;
class SearchProvider implements IProvider
{
public function __construct(
private readonly IL10N $l10n,
private readonly IURLGenerator $urlGenerator,
private readonly MultiPodService $multiPodService
private IL10N $l10n,
private IURLGenerator $urlGenerator,
private MultiPodService $multiPodService
) {}
public function getId(): string {
@ -29,11 +29,8 @@ class SearchProvider implements IProvider
return $this->l10n->t('Podcast');
}
/**
* @param array<string> $routeParameters
*/
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
return -1;
}

View File

@ -10,8 +10,8 @@ use OCP\L10N\IFactory;
class UserService
{
public function __construct(
private readonly IFactory $l10n,
private readonly IUserSession $userSession
private IFactory $l10n,
private IUserSession $userSession
) {}
public function getUserUID(): string {
@ -27,7 +27,7 @@ class UserService
public function getCountryCode(): string {
$isoCodes = explode('_', $this->getIsoCode());
return $isoCodes[1] ?? 'us';
return isset($isoCodes[1]) ? $isoCodes[1] : 'us';
}
public function getLangCode(): string {

12713
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,48 +1,38 @@
{
"name": "repod",
"license": "AGPL-3.0-or-later",
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite --mode development build",
"lint": "vue-tsc && eslint src",
"lint:fix": "vue-tsc && eslint src --fix",
"stylelint": "stylelint src/**/*.vue src/**/*.scss src/**/*.css",
"stylelint:fix": "stylelint src/**/*.vue src/**/*.scss src/**/*.css --fix",
"watch": "vite --mode development build --watch"
"build": "NODE_ENV=production webpack --config webpack.js --progress",
"dev": "NODE_ENV=development webpack --config webpack.js --progress",
"watch": "NODE_ENV=development webpack --config webpack.js --progress --watch",
"lint": "eslint src",
"stylelint": "stylelint src/**/*.vue src/**/*.scss src/**/*.css"
},
"browserslist": [
"extends @nextcloud/browserslist-config"
],
"prettier": "@nextcloud/prettier-config",
"dependencies": {
"@nextcloud/axios": "^2.5.1",
"@nextcloud/axios": "^2.5.0",
"@nextcloud/dialogs": "^5.3.1",
"@nextcloud/initial-state": "^2.2.0",
"@nextcloud/l10n": "^3.2.0",
"@nextcloud/l10n": "^3.1.0",
"@nextcloud/router": "^3.0.1",
"@nextcloud/vite-config": "^2.3.3",
"@nextcloud/vue": "~9.0.0-rc.0",
"dompurify": "^3.2.5",
"linkify-html": "^4.2.0",
"pinia": "^3.0.2",
"toastify-js": "^1.12.0",
"vite": "^6.3.3",
"vue": "^3.5.13",
"vue-material-design-icons": "^5.3.1",
"vue-router": "^4.5.1"
"@nextcloud/vue": "^8.12.0",
"vue": "^2",
"vue-material-design-icons": "^5.3.0",
"vue-router": "^3",
"vuex": "^3"
},
"devDependencies": {
"@nextcloud/browserslist-config": "^3.0.1",
"@nextcloud/eslint-config": "^8.4.2",
"@nextcloud/prettier-config": "^1.2.0",
"@nextcloud/eslint-config": "^8.4.1",
"@nextcloud/prettier-config": "^1.1.0",
"@nextcloud/stylelint-config": "^3.0.1",
"@types/node": "~20.17.32",
"@types/toastify-js": "^1.12.3",
"@vue/tsconfig": "^0.7.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-pinia": "^0.4.1",
"eslint-plugin-prettier": "^5.2.6",
"typescript": "~5.5.4",
"vue-tsc": "^2.2.10"
"@nextcloud/webpack-vue-config": "^6.0.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-webpack-plugin": "^4.2.0",
"stylelint-webpack-plugin": "^5.0.1"
}
}

View File

@ -1,6 +0,0 @@
parameters:
level: 9
paths:
- lib
includes:
- vendor/phpstan/phpstan-deprecation-rules/rules.neon

29
psalm.xml Normal file
View File

@ -0,0 +1,29 @@
<?xml version="1.0"?>
<psalm
errorLevel="1"
resolveFromConfigFile="true"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://getpsalm.org/schema/config"
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
findUnusedBaselineEntry="true"
findUnusedCode="false"
phpVersion="8.1"
>
<projectFiles>
<directory name="lib" />
<directory name="stubs" />
<ignoreFiles>
<directory name="vendor" />
</ignoreFiles>
</projectFiles>
<extraFiles>
<directory name="vendor" />
</extraFiles>
<issueHandlers>
<InvalidReturnType>
<errorLevel type="suppress">
<directory name="stubs" />
</errorLevel>
</InvalidReturnType>
</issueHandlers>
</psalm>

View File

@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
use Nextcloud\Rector\Set\NextcloudSets;
use Rector\Config\RectorConfig;
return RectorConfig::configure()
->withPaths([
__DIR__.'/lib',
])
->withPhpSets(php81: true)
->withSets([
NextcloudSets::NEXTCLOUD_30,
])
->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,
)
;

View File

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

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,15 +7,13 @@
</NcContent>
</template>
<script lang="ts">
import 'toastify-js/src/toastify.css'
import { mapActions, mapState } from 'pinia'
<script>
import '@nextcloud/dialogs/style.css'
import Bar from './components/Player/Bar.vue'
import GPodder from './views/GPodder.vue'
import { NcContent } from '@nextcloud/vue'
import Subscriptions from './components/Sidebar/Subscriptions.vue'
import { loadState } from '@nextcloud/initial-state'
import { usePlayer } from './store/player.ts'
export default {
name: 'App',
@ -26,16 +24,9 @@ export default {
Subscriptions,
},
computed: {
...mapState(usePlayer, ['paused']),
gpodder() {
return loadState('repod', 'gpodder', false)
},
},
mounted() {
this.init()
},
methods: {
...mapActions(usePlayer, ['init']),
},
}
</script>

View File

@ -1,13 +1,11 @@
<template>
<NcAppContent :class="{ episode }">
<NcAppContent :class="episode ? 'padding' : ''">
<slot />
</NcAppContent>
</template>
<script lang="ts">
<script>
import { NcAppContent } from '@nextcloud/vue'
import { mapState } from 'pinia'
import { usePlayer } from '../../store/player.ts'
export default {
name: 'AppContent',
@ -15,13 +13,15 @@ export default {
NcAppContent,
},
computed: {
...mapState(usePlayer, ['episode']),
episode() {
return this.$store.state.player.episode
},
},
}
</script>
<style scoped>
.episode {
.padding {
padding-bottom: 6rem;
}
</style>

View File

@ -1,9 +1,6 @@
<template>
<NcAppNavigation :class="{ episode }">
<NcAppNavigation :class="episode ? 'padding' : ''">
<slot />
<template #search>
<slot name="search" />
</template>
<template #list>
<slot name="list" />
</template>
@ -13,10 +10,8 @@
</NcAppNavigation>
</template>
<script lang="ts">
<script>
import { NcAppNavigation } from '@nextcloud/vue'
import { mapState } from 'pinia'
import { usePlayer } from '../../store/player.ts'
export default {
name: 'AppNavigation',
@ -24,19 +19,15 @@ export default {
NcAppNavigation,
},
computed: {
...mapState(usePlayer, ['episode']),
episode() {
return this.$store.state.player.episode
},
},
}
</script>
<style scoped>
.episode {
.padding {
padding-bottom: 6rem;
}
</style>
<style>
#app-navigation-vue .app-navigation__body {
overflow-y: hidden;
}
</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" />
</template>
<script lang="ts">
<script>
import { NcLoadingIcon } from '@nextcloud/vue'
export default {

View File

@ -1,45 +1,32 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div class="flex">
<NcAvatar
:display-name="episode.name"
:is-no-user="true"
:size="256"
:url="episode.image" />
<h2>{{ episode.name }}</h2>
<i v-if="episode.author">{{ episode.author }}</i>
<br />
<SafeHtml :source="episode.description || ''" />
<div class="flex">
<NcButton v-if="episode.link" :href="episode.link" target="_blank">
<div>
<NcAvatar :display-name="name" :is-no-user="true" :size="256" :url="image" />
<h2>{{ name }}</h2>
<p v-html="strippedDescription" />
<div>
<NcButton v-if="link" :href="link" target="_blank">
<template #icon>
<OpenInNewIcon :size="20" />
</template>
{{ episode.title }}
{{ title }}
</NcButton>
<NcButton
v-if="episode.url"
:download="filenameFromUrl(episode.url)"
:href="episode.url"
target="_blank">
<NcButton v-if="url" :href="url" target="_blank">
<template #icon>
<DownloadIcon :size="20" />
</template>
{{ t('repod', 'Download') }}
{{ episode.size ? `(${humanFileSize(episode.size)})` : '' }}
{{ t('repod', 'Download') }} {{ size ? `(${episodeFileSize})` : '' }}
</NcButton>
</div>
</div>
</template>
<script lang="ts">
<script>
import { NcAvatar, NcButton } from '@nextcloud/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 SafeHtml from './SafeHtml.vue'
import { filenameFromUrl } from '../../utils/url.ts'
import { humanFileSize } from '../../utils/size.ts'
import { t } from '@nextcloud/l10n'
import { cleanHtml } from '../../utils/text.js'
import { humanFileSize } from '../../utils/size.js'
export default {
name: 'Modal',
@ -48,24 +35,50 @@ export default {
NcAvatar,
NcButton,
OpenInNewIcon,
SafeHtml,
},
props: {
episode: {
type: Object as () => EpisodeInterface,
description: {
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,
},
},
methods: {
filenameFromUrl,
humanFileSize,
t,
computed: {
episodeFileSize() {
return humanFileSize(this.size)
},
strippedDescription() {
return cleanHtml(this.description)
},
},
}
</script>
<style scoped>
.flex {
div {
align-items: center;
display: flex;
flex-direction: column;

View File

@ -1,78 +0,0 @@
<template>
<div ref="html" v-sanitize="source" class="html" />
</template>
<script lang="ts">
import dompurify from 'dompurify'
import linkifyHtml from 'linkify-html'
import { mapActions } from 'pinia'
import { t } from '@nextcloud/l10n'
import { timeToSeconds } from '../../utils/time.ts'
import { usePlayer } from '../../store/player.ts'
export default {
name: 'SafeHtml',
directives: {
sanitize: {
mounted(el, binding) {
el.innerHTML = dompurify
.sanitize(
linkifyHtml(binding.value, {
nl2br: true,
target: '_blank',
}),
)
.replace(
/(([0-9]?[0-9]):)?([0-5]?[0-9]):([0-5][0-9])/g,
(
match,
noop: string,
hours: string,
minutes: string,
seconds: string,
) =>
`<seekable time="${timeToSeconds(
parseInt(hours),
parseInt(minutes),
parseInt(seconds),
)}" title="${t('repod', 'Skip to {match}', { match })}">${
match
}</seekable>`,
)
},
},
},
props: {
source: {
type: String,
required: true,
},
},
mounted() {
const seekables = (this.$refs.html as HTMLElement).querySelectorAll(
'seekable',
)
for (const seekable of seekables) {
seekable.addEventListener('click', (event) => {
this.seek(
parseInt(
(event.target as HTMLElement).getAttribute('time') || '',
),
)
this.play()
})
}
},
methods: {
...mapActions(usePlayer, ['play', 'seek']),
},
}
</script>
<style>
.html a,
seekable {
cursor: pointer;
text-decoration: underline;
}
</style>

View File

@ -1,8 +1,8 @@
<template>
<NcAppNavigationList class="list">
<NcAppNavigationList>
<NcAppNavigationNewItem
:name="t('repod', 'Add a RSS link')"
@new-item="(url: string) => $router.push(toFeedUrl(url))">
@new-item="addSubscription">
<template #icon>
<PlusIcon :size="20" />
</template>
@ -10,11 +10,10 @@
</NcAppNavigationList>
</template>
<script lang="ts">
<script>
import { NcAppNavigationList, NcAppNavigationNewItem } from '@nextcloud/vue'
import PlusIcon from 'vue-material-design-icons/Plus.vue'
import { t } from '@nextcloud/l10n'
import { toFeedUrl } from '../../utils/url.ts'
import { encodeUrl } from '../../utils/url.js'
export default {
name: 'AddRss',
@ -24,14 +23,15 @@ export default {
PlusIcon,
},
methods: {
t,
toFeedUrl,
addSubscription(feedUrl) {
this.$router.push(encodeUrl(feedUrl))
},
},
}
</script>
<style scoped>
.list {
ul {
margin-top: 2rem;
}
</style>

View File

@ -7,7 +7,7 @@
:key="feed.link"
:details="formatLocaleDate(new Date(feed.fetchedAtUnix * 1000))"
:name="feed.title"
:to="toFeedUrl(feed.link)">
:to="toUrl(feed.link)">
<template #icon>
<NcAvatar
:display-name="feed.author"
@ -19,8 +19,7 @@
</template>
<template #actions>
<NcActionButton
v-if="!getSubByUrl(feed.link)"
:aria-label="t('repod', 'Subscribe')"
v-if="!isSubscribed"
:name="t('repod', 'Subscribe')"
:title="t('repod', 'Subscribe')"
@click="addSubscription(feed.link)">
@ -34,19 +33,16 @@
</div>
</template>
<script lang="ts">
<script>
import { NcActionButton, NcAvatar, NcListItem } from '@nextcloud/vue'
import { mapActions, mapState } from 'pinia'
import Loading from '../Atoms/Loading.vue'
import PlusIcon from 'vue-material-design-icons/Plus.vue'
import type { PodcastDataInterface } from '../../utils/types.ts'
import axios from '@nextcloud/axios'
import { formatLocaleDate } from '../../utils/time.ts'
import { debounce } from '../../utils/debounce.js'
import { formatLocaleDate } from '../../utils/time.js'
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'
import { showError } from '@nextcloud/dialogs'
import { toUrl } from '../../utils/url.js'
export default {
name: 'Search',
@ -63,28 +59,21 @@ export default {
required: true,
},
},
data: () => ({
feeds: [] as PodcastDataInterface[],
loading: false,
timeout: null as NodeJS.Timeout | null,
}),
computed: {
...mapState(useSubscriptions, ['getSubByUrl']),
data() {
return {
feeds: [],
loading: false,
}
},
watch: {
value() {
if (this.timeout) {
clearTimeout(this.timeout)
}
this.timeout = setTimeout(this.search, 200)
this.search()
},
},
methods: {
...mapActions(useSubscriptions, ['fetch']),
formatLocaleDate,
t,
toFeedUrl,
async addSubscription(url: string) {
toUrl,
async addSubscription(url) {
try {
await axios.post(
generateUrl('/apps/gpoddersync/subscription_change/create'),
@ -97,21 +86,21 @@ export default {
console.error(e)
showError(t('repod', 'Error while adding the feed'))
}
this.fetch()
this.$store.dispatch('subscriptions/fetch')
},
async search() {
search: debounce(async function value() {
try {
this.loading = true
const currentSearch = this.value
const feeds = await axios.get<PodcastDataInterface[]>(
const feeds = await axios.get(
generateUrl('/apps/repod/search?q={value}', {
value: currentSearch,
}),
)
if (currentSearch === this.value) {
this.feeds = [...feeds.data].sort(
(a: PodcastDataInterface, b: PodcastDataInterface) =>
b.fetchedAtUnix - a.fetchedAtUnix,
(a, b) => b.fetchedAtUnix - a.fetchedAtUnix,
)
}
} catch (e) {
@ -122,7 +111,7 @@ export default {
this.loading = false
}
}
},
}, 200),
},
}
</script>

View File

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

View File

@ -1,13 +1,14 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div class="header">
<img class="background" :src="feed.imageUrl" />
<img class="background" :src="imageUrl" />
<div class="content">
<div>
<NcAvatar
:display-name="feed.author || feed.title"
:display-name="author || title"
:is-no-user="true"
:size="128"
:url="feed.imageUrl" />
:url="imageUrl" />
<a class="feed" :href="url" @click.prevent="copyFeed">
<RssIcon :size="20" />
<i>{{ t('repod', 'Copy feed') }}</i>
@ -15,15 +16,17 @@
</div>
<div class="inner">
<div class="infos">
<h2>{{ feed.title }}</h2>
<a :href="feed.link" target="_blank">
<i>{{ feed.author }}</i>
<h2>{{ title }}</h2>
<a :href="link" target="_blank">
<i>{{ author }}</i>
</a>
<br /><br />
<SafeHtml :source="feed.description || ''" />
<p>
<small v-html="strippedDescription" />
</p>
</div>
<NcAppNavigationNew
v-if="!getSubByUrl(url)"
v-if="!isSubscribed"
:text="t('repod', 'Subscribe')"
@click="addSubscription">
<template #icon>
@ -35,19 +38,15 @@
</div>
</template>
<script lang="ts">
<script>
import { NcAppNavigationNew, NcAvatar } from '@nextcloud/vue'
import { mapActions, mapState } from 'pinia'
import { showError, showSuccess } from '../../utils/toast.ts'
import { showError, showSuccess } from '@nextcloud/dialogs'
import PlusIcon from 'vue-material-design-icons/Plus.vue'
import type { PodcastDataInterface } from '../../utils/types.ts'
import RssIcon from 'vue-material-design-icons/Rss.vue'
import SafeHtml from '../Atoms/SafeHtml.vue'
import axios from '@nextcloud/axios'
import { decodeUrl } from '../../utils/url.ts'
import { cleanHtml } from '../../utils/text.js'
import { decodeUrl } from '../../utils/url.js'
import { generateUrl } from '@nextcloud/router'
import { t } from '@nextcloud/l10n'
import { useSubscriptions } from '../../store/subscriptions.ts'
export default {
name: 'Banner',
@ -56,23 +55,41 @@ export default {
NcAppNavigationNew,
PlusIcon,
RssIcon,
SafeHtml,
},
props: {
feed: {
type: Object as () => PodcastDataInterface,
author: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
imageUrl: {
type: String,
required: true,
},
link: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
},
computed: {
...mapState(useSubscriptions, ['getSubByUrl']),
url() {
return decodeUrl(this.$route.params.url as string)
return decodeUrl(this.$route.params.url)
},
isSubscribed() {
return this.$store.state.subscriptions.subscriptions.includes(this.url)
},
strippedDescription() {
return cleanHtml(this.description)
},
},
methods: {
...mapActions(useSubscriptions, ['fetch']),
t,
async addSubscription() {
try {
await axios.post(
@ -86,11 +103,12 @@ export default {
console.error(e)
showError(t('repod', 'Error while adding the feed'))
}
this.fetch()
this.$store.dispatch('subscriptions/fetch')
},
copyFeed() {
window.navigator.clipboard.writeText(this.url)
showSuccess(t('repod', 'Link copied to the clipboard'))
showSuccess(t('repod', "Feed's link copied to the clipboard"))
},
},
}
@ -129,13 +147,11 @@ export default {
}
.infos {
flex: 1;
overflow: auto;
}
.inner {
display: flex;
flex: 1;
}
@media only screen and (max-width: 768px) {

View File

@ -1,232 +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 }"
@click="modalEpisode = episode">
<template #extra-actions>
<NcActionButton
v-if="!isCurrentEpisode(episode)"
:aria-label="t('repod', 'Play')"
:title="t('repod', 'Play')"
@click="load(episode, url)">
<template #icon>
<PlayIcon class="pointer" :size="20" />
</template>
</NcActionButton>
<NcActionButton
v-if="isCurrentEpisode(episode)"
:aria-label="t('repod', 'Stop')"
:title="t('repod', 'Stop')"
@click="load(null)">
<template #icon>
<StopIcon class="pointer" :size="20" />
</template>
</NcActionButton>
</template>
<template #actions>
<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="read(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>
</template>
<template #extra>
<NcModal
v-if="modalEpisode"
:close-on-click-outside="true"
@close="modalEpisode = null">
<Modal :episode="episode" />
</NcModal>
</template>
<template #icon>
<NcAvatar
:display-name="episode.name"
:is-no-user="true"
:title="t('repod', 'Select')"
:url="episode.image"
@click.stop="$emit('select', episode)">
<template #icon>
<CheckIcon v-if="episode.selected" class="progress" :size="20" />
</template>
</NcAvatar>
</template>
<template #indicator>
<div class="indicator">
<NcProgressBar
v-if="episode.action && isListening(episode) && !oneLine"
class="progress"
:value="
(episode.action.position * 100) / episode.action.total
" />
{{ episode.duration }}
</div>
</template>
<template v-if="!oneLine" #subname>
{{ episode.author }}
</template>
</NcListItem>
</template>
<script lang="ts">
import {
NcActionButton,
NcActionLink,
NcAvatar,
NcListItem,
NcModal,
NcProgressBar,
} from '@nextcloud/vue'
import { hasEnded, isListening, markAs } from '../../utils/status.ts'
import { mapActions, mapState } from 'pinia'
import CheckIcon from 'vue-material-design-icons/Check.vue'
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 { formatLocaleDate } from '../../utils/time.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: {
CheckIcon,
DownloadIcon,
Modal,
NcActionButton,
NcActionLink,
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,
},
},
emits: ['select'],
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 read(episode: EpisodeInterface, read: boolean) {
try {
this.loading = true
episode = markAs(episode, read, this.url)
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;
}
.indicator {
display: flex;
gap: 1rem;
}
.pointer {
cursor: pointer;
}
</style>
<style>
.episode .list-item--one-line .list-item-content__name {
max-width: 100%;
}
.episode .list-item--one-line .list-item-content__subname {
flex-basis: auto;
flex-grow: 0;
}
</style>

View File

@ -3,180 +3,211 @@
<Loading v-if="loading" />
<ul v-if="!loading">
<NcListItem
v-if="!episodes.every((e: EpisodeInterface) => !e.selected)"
:active="true"
v-for="episode in filteredEpisodes"
:key="episode.guid"
:active="isCurrentEpisode(episode)"
:class="hasEnded(episode) ? 'ended' : ''"
:details="formatLocaleDate(new Date(episode.pubDate?.date))"
:force-display-actions="true"
:name="
n(
'repod',
'%n episode selected',
'%n episodes selected',
episodes.filter((e: EpisodeInterface) => e.selected).length,
)
"
:one-line="true">
:name="episode.name"
:title="episode.description"
@click="modalEpisode = episode">
<template #actions>
<NcActionButton
v-if="
episodes
.filter((e: EpisodeInterface) => e.selected)
.filter((e: EpisodeInterface) => !hasEnded(e)).length
"
:aria-label="t('repod', 'Read all')"
:disabled="
!episodes
.filter((e: EpisodeInterface) => e.selected)
.every((e: EpisodeInterface) => e.duration)
"
:name="t('repod', 'Read all')"
:title="t('repod', 'Read all')"
@click="read(true)">
v-if="!isCurrentEpisode(episode)"
:aria-label="t('repod', 'Play')"
:name="t('repod', 'Play')"
:title="t('repod', 'Play')"
@click="load(episode)">
<template #icon>
<PlaylistPlayIcon :size="20" />
<PlayIcon :size="20" />
</template>
</NcActionButton>
<NcActionButton
v-if="
episodes
.filter((e: EpisodeInterface) => e.selected)
.every((e: EpisodeInterface) => hasEnded(e))
"
:aria-label="t('repod', 'Unread all')"
:disabled="
!episodes
.filter((e: EpisodeInterface) => e.selected)
.every((e: EpisodeInterface) => e.duration)
"
:name="t('repod', 'Unread all')"
:title="t('repod', 'Unread all')"
@click="read(false)">
v-if="isCurrentEpisode(episode)"
:aria-label="t('repod', 'Stop')"
:name="t('repod', 'Stop')"
:title="t('repod', 'Stop')"
@click="load(null)">
<template #icon>
<PlaylistRemoveIcon :size="20" />
<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"
: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
v-if="
episodes.filter((e: EpisodeInterface) => e.selected)
.length < episodes.length
"
class="avatar"
:display-name="t('repod', 'Select all')"
:is-no-user="true">
<template #icon>
<SelectAllIcon
class="progress"
:size="20"
@click="select(true)" />
</template>
</NcAvatar>
<NcAvatar
v-if="
episodes.filter((e: EpisodeInterface) => e.selected)
.length >= episodes.length
"
class="avatar"
:display-name="t('repod', 'Unselect all')"
:is-no-user="true">
<template #icon>
<SelectRemoveIcon
class="progress"
:size="20"
@click="select(false)" />
</template>
</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>
<Episode
v-for="episode in filteredEpisodes"
:key="episode.guid"
:episode="episode"
:url="url"
@select="episode.selected = !episode.selected" />
</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>
</template>
<script lang="ts">
import { NcActionButton, NcAvatar, NcListItem } from '@nextcloud/vue'
import { hasEnded, isListening, markAs } from '../../utils/status.ts'
import { n, t } from '@nextcloud/l10n'
import Episode from './Episode.vue'
import type { EpisodeInterface } from '../../utils/types.ts'
<script>
import {
NcActionButton,
NcActionLink,
NcActions,
NcAvatar,
NcListItem,
NcModal,
NcProgressBar,
} from '@nextcloud/vue'
import {
durationToSeconds,
formatEpisodeTimestamp,
formatLocaleDate,
} from '../../utils/time.js'
import DownloadIcon from 'vue-material-design-icons/Download.vue'
import { EventBus } from '../../store/bus.js'
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 SelectAllIcon from 'vue-material-design-icons/SelectAll.vue'
import SelectRemoveIcon from 'vue-material-design-icons/SelectRemove.vue'
import StopIcon from 'vue-material-design-icons/Stop.vue'
import axios from '@nextcloud/axios'
import { decodeUrl } from '../../utils/url.ts'
import { decodeUrl } from '../../utils/url.js'
import { generateUrl } from '@nextcloud/router'
import { mapState } from 'pinia'
import { showError } from '../../utils/toast.ts'
import { usePlayer } from '../../store/player.ts'
import { useSettings } from '../../store/settings.ts'
import { showError } from '@nextcloud/dialogs'
export default {
name: 'Episodes',
components: {
Episode,
DownloadIcon,
Loading,
Modal,
NcActionButton,
NcActionLink,
NcActions,
NcAvatar,
NcListItem,
NcModal,
NcProgressBar,
OpenInNewIcon,
PlayIcon,
PlaylistPlayIcon,
PlaylistRemoveIcon,
SelectAllIcon,
SelectRemoveIcon,
StopIcon,
},
data() {
return {
episodes: [],
loading: true,
loadingAction: false,
modalEpisode: null,
}
},
data: () => ({
episodes: [] as EpisodeInterface[],
loading: true,
}),
computed: {
...mapState(usePlayer, ['episode']),
...mapState(useSettings, ['filters']),
currentEpisode() {
return this.$store.state.player.episode
},
filters() {
return this.$store.state.settings.filters
},
filteredEpisodes() {
return this.episodes.filter((episode: EpisodeInterface) => {
return this.episodes.filter((episode) => {
if (!this.filters.listened && this.hasEnded(episode)) {
return false
}
if (!this.filters.listening && this.isListening(episode)) {
return false
}
if (!this.filters.unlistened && !this.isListening(episode)) {
return false
}
return true
})
},
url() {
return decodeUrl(this.$route.params.url as string)
},
},
watch: {
episode() {
if (this.episode) {
this.episodes = this.episodes.map((episode: EpisodeInterface) =>
episode.url === this.episode?.url ? this.episode : episode,
)
}
return decodeUrl(this.$route.params.url)
},
},
async mounted() {
try {
this.loading = true
const episodes = await axios.get<EpisodeInterface[]>(
const episodes = await axios.get(
generateUrl('/apps/repod/episodes/list?url={url}', {
url: this.url,
}),
)
this.episodes = [...episodes.data].sort(
(a: EpisodeInterface, b: EpisodeInterface) =>
new Date(b.pubDate?.date || '').getTime()
- new Date(a.pubDate?.date || '').getTime(),
(a, b) => new Date(b.pubDate?.date) - new Date(a.pubDate?.date),
)
EventBus.$on('updateEpisodesList', this.updateList)
} catch (e) {
console.error(e)
showError(t('repod', 'Could not fetch episodes'))
@ -184,40 +215,69 @@ export default {
this.loading = false
}
},
destroyed() {
EventBus.$off('updateEpisodesList')
},
methods: {
hasEnded,
isListening,
n,
t,
async read(read: boolean) {
formatLocaleDate,
hasEnded(episode) {
return (
episode.action &&
episode.action.position > 0 &&
episode.action.total > 0 &&
episode.action.position >= episode.action.total
)
},
isCurrentEpisode(episode) {
return this.currentEpisode && this.currentEpisode.url === episode.url
},
isListening(episode) {
return (
episode.action &&
episode.action.action.toLowerCase() === 'play' &&
!this.hasEnded(episode)
)
},
load(episode) {
this.$store.dispatch('player/load', episode)
},
async markAs(episode, read) {
try {
this.episodes = this.episodes.map((episode: EpisodeInterface) =>
episode.selected ? markAs(episode, read, this.url) : episode,
)
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'),
this.episodes
.filter((episode: EpisodeInterface) => episode.selected)
.map((episode: EpisodeInterface) => episode.action),
[episode.action],
)
this.updateList(episode)
} catch (e) {
console.error(e)
showError(t('repod', 'Could not change the status of the episode'))
} finally {
this.loadingAction = false
}
},
select(all: boolean) {
this.episodes = this.episodes.map((episode: EpisodeInterface) => {
episode.selected = all
return episode
})
updateList(episode) {
this.episodes = this.episodes.map((e) =>
e.url === episode.url ? episode : e,
)
},
},
}
</script>
<style scoped>
.avatar {
color: var(--color-main-text);
.ended {
opacity: 0.4;
}
.progress {

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: EpisodeInterface, b: EpisodeInterface) =>
new Date(b.pubDate?.date || '').getTime()
- new Date(a.pubDate?.date || '').getTime(),
)
.filter((episode: EpisodeInterface) => !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

@ -1,10 +1,10 @@
<template>
<div v-if="episode" class="footer">
<img class="background" :src="episode.image" />
<Loading v-if="!loaded" />
<ProgressBar v-if="loaded" />
<div v-if="loaded" class="player">
<img :src="episode.image" />
<div v-if="player.episode" class="footer">
<img class="background" :src="player.episode.image" />
<Loading v-if="!player.loaded" />
<ProgressBar v-if="player.loaded" />
<div v-if="player.loaded" class="player">
<img :src="player.episode.image" />
<Infos class="infos" />
<Controls class="controls" />
<Timer class="timer" />
@ -13,15 +13,13 @@
</div>
</template>
<script lang="ts">
<script>
import Controls from './Controls.vue'
import Infos from './Infos.vue'
import Loading from '../Atoms/Loading.vue'
import ProgressBar from './ProgressBar.vue'
import Timer from './Timer.vue'
import Volume from './Volume.vue'
import { mapState } from 'pinia'
import { usePlayer } from '../../store/player.ts'
export default {
name: 'Bar',
@ -34,7 +32,9 @@ export default {
Volume,
},
computed: {
...mapState(usePlayer, ['episode', 'loaded']),
player() {
return this.$store.state.player
},
},
}
</script>
@ -78,7 +78,7 @@ export default {
@media only screen and (max-width: 768px) {
.infos {
flex: 1;
flex: 2;
}
.timer,

View File

@ -1,53 +1,32 @@
<template>
<div class="controls">
<Rewind10Icon
class="pointer rewind"
:size="20"
:title="t('repod', 'Rewind 10 seconds')"
@click="seek((currentTime ?? 0) - 10)" />
<PauseIcon
v-if="!paused"
v-if="!player.paused"
class="pointer"
:size="50"
:title="t('repod', 'Pause')"
@click="pause" />
@click="$store.dispatch('player/pause')" />
<PlayIcon
v-if="paused"
v-if="player.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)" />
@click="$store.dispatch('player/play')" />
</div>
</template>
<script lang="ts">
import { mapActions, mapState } from 'pinia'
import FastForward30Icon from 'vue-material-design-icons/FastForward30.vue'
<script>
import PauseIcon from 'vue-material-design-icons/Pause.vue'
import PlayIcon from 'vue-material-design-icons/Play.vue'
import Rewind10Icon from 'vue-material-design-icons/Rewind10.vue'
import { t } from '@nextcloud/l10n'
import { usePlayer } from '../../store/player.ts'
export default {
name: 'Controls',
components: {
FastForward30Icon,
PauseIcon,
PlayIcon,
Rewind10Icon,
},
computed: {
...mapState(usePlayer, ['currentTime', 'paused']),
},
methods: {
...mapActions(usePlayer, ['play', 'pause', 'seek']),
t,
player() {
return this.$store.state.player
},
},
}
</script>
@ -55,17 +34,9 @@ export default {
<style scoped>
.controls {
display: flex;
gap: 0.5rem;
}
.pointer {
cursor: pointer;
}
@media only screen and (max-width: 768px) {
.forward,
.rewind {
display: none;
}
}
</style>

View File

@ -1,23 +1,28 @@
<template>
<div v-if="episode && podcastUrl" class="root">
<div class="root">
<strong class="pointer" @click="modal = true">
{{ episode.name }}
{{ player.episode.name }}
</strong>
<router-link :to="toFeedUrl(podcastUrl)">
<i>{{ episode.title }}</i>
<router-link :to="hash">
<i>{{ player.episode.title }}</i>
</router-link>
<NcModal v-if="modal" :close-on-click-outside="true" @close="modal = false">
<Modal :episode="episode" />
<NcModal v-if="modal" @close="modal = false">
<Modal
:description="player.episode.description"
:image="player.episode.image"
:link="player.episode.link"
:name="player.episode.name"
:size="player.episode.size"
:title="player.episode.title"
:url="player.episode.url" />
</NcModal>
</div>
</template>
<script lang="ts">
<script>
import Modal from '../Atoms/Modal.vue'
import { NcModal } from '@nextcloud/vue'
import { mapState } from 'pinia'
import { toFeedUrl } from '../../utils/url.ts'
import { usePlayer } from '../../store/player.ts'
import { toUrl } from '../../utils/url.js'
export default {
name: 'Infos',
@ -25,14 +30,18 @@ export default {
Modal,
NcModal,
},
data: () => ({
modal: false,
}),
computed: {
...mapState(usePlayer, ['episode', 'podcastUrl']),
data() {
return {
modal: false,
}
},
methods: {
toFeedUrl,
computed: {
player() {
return this.$store.state.player
},
hash() {
return toUrl(this.player.podcastUrl)
},
},
}
</script>

View File

@ -1,27 +1,20 @@
<template>
<input
v-if="duration"
class="progress"
:max="duration"
:max="player.duration"
min="0"
type="range"
:value="currentTime"
@change="
(event) => seek(parseInt((event.target as HTMLInputElement).value))
" />
:value="player.currentTime"
@change="(event) => $store.dispatch('player/seek', event.target.value)" />
</template>
<script lang="ts">
import { mapActions, mapState } from 'pinia'
import { usePlayer } from '../../store/player.ts'
<script>
export default {
name: 'ProgressBar',
computed: {
...mapState(usePlayer, ['duration', 'currentTime']),
},
methods: {
...mapActions(usePlayer, ['seek']),
player() {
return this.$store.state.player
},
},
}
</script>

View File

@ -1,20 +1,20 @@
<template>
<div v-if="currentTime && duration" class="root">
<span>{{ formatTimer(new Date(currentTime * 1000)) }}</span>
<div>
<span>{{ formatTimer(new Date(player.currentTime * 1000)) }}</span>
<span>/</span>
<span>{{ formatTimer(new Date(duration * 1000)) }}</span>
<span>{{ formatTimer(new Date(player.duration * 1000)) }}</span>
</div>
</template>
<script lang="ts">
import { formatTimer } from '../../utils/time.ts'
import { mapState } from 'pinia'
import { usePlayer } from '../../store/player.ts'
<script>
import { formatTimer } from '../../utils/time.js'
export default {
name: 'Timer',
computed: {
...mapState(usePlayer, ['duration', 'currentTime']),
player() {
return this.$store.state.player
},
},
methods: {
formatTimer,
@ -23,7 +23,7 @@ export default {
</script>
<style scoped>
.root {
div {
align-items: center;
display: flex;
gap: 5px;

View File

@ -1,50 +1,42 @@
<template>
<div>
<VolumeHighIcon
v-if="volume > 0.7"
v-if="player.volume > 0.7"
class="pointer"
:size="30"
:title="t('repod', 'Mute')"
@click="mute" />
<VolumeLowIcon
v-if="volume > 0 && volume <= 0.3"
v-if="player.volume > 0 && player.volume <= 0.3"
class="pointer"
:size="30"
:title="t('repod', 'Mute')"
@click="mute" />
<VolumeMediumIcon
v-if="volume > 0.3 && volume <= 0.7"
v-if="player.volume > 0.3 && player.volume <= 0.7"
class="pointer"
:size="30"
:title="t('repod', 'Mute')"
@click="mute" />
<VolumeMuteIcon
v-if="volume === 0"
v-if="player.volume === 0"
class="pointer"
:size="30"
:title="t('repod', 'Unmute')"
@click="setVolume(volumeMuted)" />
@click="$store.dispatch('player/volume', volumeMuted)" />
<input
max="1"
min="0"
step="0.1"
type="range"
:value="volume"
:value="player.volume"
@change="
(event) =>
setVolume(parseFloat((event.target as HTMLInputElement).value))
(event) => $store.dispatch('player/volume', event.target.value)
" />
</div>
</template>
<script lang="ts">
import { mapActions, mapState } from 'pinia'
<script>
import VolumeHighIcon from 'vue-material-design-icons/VolumeHigh.vue'
import VolumeLowIcon from 'vue-material-design-icons/VolumeLow.vue'
import VolumeMediumIcon from 'vue-material-design-icons/VolumeMedium.vue'
import VolumeMuteIcon from 'vue-material-design-icons/VolumeMute.vue'
import { t } from '@nextcloud/l10n'
import { usePlayer } from '../../store/player.ts'
export default {
name: 'Volume',
@ -54,19 +46,21 @@ export default {
VolumeMediumIcon,
VolumeMuteIcon,
},
data: () => ({
volumeMuted: 0,
}),
data() {
return {
volumeMuted: 0,
}
},
computed: {
...mapState(usePlayer, ['volume']),
player() {
return this.$store.state.player
},
},
methods: {
...mapActions(usePlayer, ['setVolume']),
mute() {
this.volumeMuted = this.volume
this.setVolume(0)
this.volumeMuted = this.player.volume
this.$store.dispatch('player/volume', 0)
},
t,
},
}
</script>

View File

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

View File

@ -1,33 +1,44 @@
<template>
<NcAppNavigationItem
:allow-collapse="true"
menu-placement="top"
:name="t('repod', 'Filtering episodes')">
<template #actions>
<NcActionCheckbox
:checked="all"
:disabled="all"
:model-value="all"
@change="
setFilters({
listened: true,
listening: true,
unlistened: true,
})
@update:checked="
(checked) =>
$store.commit('settings/filters', {
listened: checked,
listening: checked,
unlistened: checked,
})
">
{{ t('repod', 'Show all') }}
</NcActionCheckbox>
<NcActionCheckbox
:model-value="filters.listened"
@change="setFilters({ listened: !filters.listened })">
:checked="filters.listened"
@update:checked="
(checked) =>
$store.commit('settings/filters', { listened: checked })
">
{{ t('repod', 'Listened') }}
</NcActionCheckbox>
<NcActionCheckbox
:model-value="filters.listening"
@change="setFilters({ listening: !filters.listening })">
:checked="filters.listening"
@update:checked="
(checked) =>
$store.commit('settings/filters', { listening: checked })
">
{{ t('repod', 'Listening') }}
</NcActionCheckbox>
<NcActionCheckbox
:model-value="filters.unlistened"
@change="setFilters({ unlistened: !filters.unlistened })">
:checked="filters.unlistened"
@update:checked="
(checked) =>
$store.commit('settings/filters', { unlistened: checked })
">
{{ t('repod', 'Unlistened') }}
</NcActionCheckbox>
</template>
@ -38,13 +49,10 @@
</NcAppNavigationItem>
</template>
<script lang="ts">
<script>
import { NcActionCheckbox, NcAppNavigationItem } from '@nextcloud/vue'
import { mapActions, mapState } from 'pinia'
import FilterIcon from 'vue-material-design-icons/Filter.vue'
import FilterSettingsIcon from 'vue-material-design-icons/FilterSettings.vue'
import { t } from '@nextcloud/l10n'
import { useSettings } from '../../store/settings.ts'
export default {
name: 'Filters',
@ -55,18 +63,19 @@ export default {
NcActionCheckbox,
},
computed: {
...mapState(useSettings, ['filters']),
all() {
return (
this.filters.listened
&& this.filters.listening
&& this.filters.unlistened
this.filters.listened &&
this.filters.listening &&
this.filters.unlistened
)
},
filters() {
return this.$store.state.settings.filters
},
},
methods: {
...mapActions(useSettings, ['setFilters']),
t,
mounted() {
this.$store.dispatch('settings/fetch')
},
}
</script>

View File

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

View File

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

View File

@ -1,7 +1,6 @@
<template>
<NcAppNavigationSettings :name="t('repod', 'Settings')">
<NcAppNavigationSettings>
<Filters />
<Sleep />
<Speed />
<Import />
<Export />
@ -9,15 +8,13 @@
</NcAppNavigationSettings>
</template>
<script lang="ts">
<script>
import Export from './Export.vue'
import Filters from './Filters.vue'
import Import from './Import.vue'
import { NcAppNavigationSettings } from '@nextcloud/vue'
import Rate from './Rate.vue'
import Sleep from './Sleep.vue'
import Speed from './Speed.vue'
import { t } from '@nextcloud/l10n'
export default {
name: 'Settings',
@ -27,11 +24,7 @@ export default {
Import,
NcAppNavigationSettings,
Rate,
Sleep,
Speed,
},
methods: {
t,
},
}
</script>

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

@ -3,31 +3,27 @@
<template #extra>
<div class="extra">
<MinusIcon class="pointer" :size="20" @click="changeRate(-0.1)" />
<NcCounterBubble
:active="rate !== 1"
class="counter"
:count="rate" />
<NcCounterBubble class="counter">
x{{ player.rate }}
</NcCounterBubble>
<PlusIcon class="pointer" :size="20" @click="changeRate(0.1)" />
</div>
</template>
<template #icon>
<SpeedometerSlowIcon v-if="rate < 1" :size="20" />
<SpeedometerMediumIcon v-if="rate === 1" :size="20" />
<SpeedometerIcon v-if="rate > 1" :size="20" />
<SpeedometerSlowIcon v-if="player.rate < 1" :size="20" />
<SpeedometerMediumIcon v-if="player.rate === 1" :size="20" />
<SpeedometerIcon v-if="player.rate > 1" :size="20" />
</template>
</NcAppNavigationItem>
</template>
<script lang="ts">
<script>
import { NcAppNavigationItem, NcCounterBubble } from '@nextcloud/vue'
import { mapActions, mapState } from 'pinia'
import MinusIcon from 'vue-material-design-icons/Minus.vue'
import PlusIcon from 'vue-material-design-icons/Plus.vue'
import SpeedometerIcon from 'vue-material-design-icons/Speedometer.vue'
import SpeedometerMediumIcon from 'vue-material-design-icons/SpeedometerMedium.vue'
import SpeedometerSlowIcon from 'vue-material-design-icons/SpeedometerSlow.vue'
import { t } from '@nextcloud/l10n'
import { usePlayer } from '../../store/player.ts'
export default {
name: 'Speed',
@ -41,14 +37,17 @@ export default {
SpeedometerSlowIcon,
},
computed: {
...mapState(usePlayer, ['rate']),
player() {
return this.$store.state.player
},
},
methods: {
...mapActions(usePlayer, ['setRate']),
t,
changeRate(diff: number) {
const newRate = parseFloat((this.rate + diff).toPrecision(2))
this.setRate(newRate > 0 ? newRate : this.rate)
changeRate(diff) {
const newRate = (this.player.rate + diff).toPrecision(2)
this.$store.dispatch(
'player/rate',
newRate > 0 ? newRate : this.player.rate,
)
},
},
}

View File

@ -0,0 +1,106 @@
<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 { showError } from '@nextcloud/dialogs'
import { toUrl } from '../../utils/url.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: {
async deleteSubscription() {
if (
confirm(
t('repod', 'Are you sure you want to delete this subscription?'),
)
) {
try {
this.loading = true
await axios.post(
generateUrl('/apps/gpoddersync/subscription_change/create'),
{ add: [], remove: [this.url] },
)
} catch (e) {
console.error(e)
showError(t('repod', 'Error while removing the feed'))
} finally {
this.loading = false
this.$store.dispatch('subscriptions/fetch')
}
}
},
},
}
</script>

View File

@ -1,156 +0,0 @@
<template>
<NcListItem
:loading="loading"
:name="feed?.data?.title || url"
:one-line="true"
:to="toFeedUrl(url)">
<template #actions>
<NcActionButton
:aria-label="t('repod', 'Favorite')"
:model-value="feed?.isFavorite"
:name="t('repod', 'Favorite')"
:title="t('repod', 'Favorite')"
@update:model-value="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>
</NcListItem>
</template>
<script lang="ts">
import { NcActionButton, NcAvatar, NcListItem } from '@nextcloud/vue'
import type {
PersonalSettingsPodcastDataInterface,
SubscriptionInterface,
} from '../../utils/types.ts'
import { mapActions, mapState } from 'pinia'
import AlertIcon from 'vue-material-design-icons/Alert.vue'
import DeleteIcon from 'vue-material-design-icons/Delete.vue'
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,
NcAvatar,
NcListItem,
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: SubscriptionInterface) => 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

@ -1,41 +1,22 @@
<template>
<AppNavigation>
<router-link to="/discover">
<NcAppNavigationNew :text="t('repod', 'Add a podcast')">
<template #icon>
<PlusIcon :size="20" />
</template>
</NcAppNavigationNew>
</router-link>
<Loading v-if="loading" />
<template #search>
<NcAppNavigationSearch
v-model="searchValue"
:label="t(`core`, 'Filter in current view')" />
</template>
<template v-if="!loading" #list>
<Subscription
v-for="sub of subs.filter(
(sub) =>
sub.isFavorite
&& (sub.data?.title
.toLowerCase()
.includes(searchValue.toLowerCase())
|| !searchValue),
)"
:key="sub.metrics.url"
:url="sub.metrics.url" />
<Subscription
v-for="sub of subs.filter(
(sub) =>
!sub.isFavorite
&& (sub.data?.title
.toLowerCase()
.includes(searchValue.toLowerCase())
|| !searchValue),
)"
:key="sub.metrics.url"
:url="sub.metrics.url" />
<template #list>
<NcAppContentList>
<router-link to="/">
<NcAppNavigationNew :text="t('repod', 'Add a podcast')">
<template #icon>
<PlusIcon :size="20" />
</template>
</NcAppNavigationNew>
</router-link>
<Loading v-if="loading" />
<NcAppNavigationList v-if="!loading">
<Item
v-for="subscriptionUrl of subscriptions"
:key="subscriptionUrl"
:url="subscriptionUrl" />
</NcAppNavigationList>
</NcAppContentList>
</template>
<template #footer>
<Settings />
@ -43,39 +24,44 @@
</AppNavigation>
</template>
<script lang="ts">
import { NcAppNavigationNew, NcAppNavigationSearch } from '@nextcloud/vue'
import { mapActions, mapState } from 'pinia'
<script>
import {
NcAppContentList,
NcAppNavigationList,
NcAppNavigationNew,
} from '@nextcloud/vue'
import AppNavigation from '../Atoms/AppNavigation.vue'
import Item from './Item.vue'
import Loading from '../Atoms/Loading.vue'
import PlusIcon from 'vue-material-design-icons/Plus.vue'
import Settings from '../Settings/Settings.vue'
import Subscription from './Subscription.vue'
import { showError } from '../../utils/toast.ts'
import { t } from '@nextcloud/l10n'
import { useSubscriptions } from '../../store/subscriptions.ts'
import { showError } from '@nextcloud/dialogs'
export default {
name: 'Subscriptions',
components: {
AppNavigation,
Item,
Loading,
NcAppContentList,
NcAppNavigationList,
NcAppNavigationNew,
NcAppNavigationSearch,
PlusIcon,
Settings,
Subscription,
},
data: () => ({
loading: true,
searchValue: '',
}),
data() {
return {
loading: true,
}
},
computed: {
...mapState(useSubscriptions, ['subs']),
subscriptions() {
return this.$store.state.subscriptions.subscriptions
},
},
async mounted() {
try {
await this.fetch()
await this.$store.dispatch('subscriptions/fetch')
} catch (e) {
console.error(e)
showError(t('repod', 'Could not fetch subscriptions'))
@ -83,9 +69,5 @@ export default {
this.loading = false
}
},
methods: {
...mapActions(useSubscriptions, ['fetch']),
t,
},
}
</script>

13
src/main.js Normal file
View File

@ -0,0 +1,13 @@
import { n, t } from '@nextcloud/l10n'
import App from './App.vue'
import Vue from 'vue'
import router from './router.js'
import store from './store/main.js'
Vue.mixin({ methods: { t, n } })
const View = Vue.extend(App)
new View({
router,
store,
}).$mount('#content')

View File

@ -1,11 +0,0 @@
import App from './App.vue'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router.ts'
const Vue = createApp(App)
const pinia = createPinia()
Vue.use(pinia)
Vue.use(router)
Vue.mount('#content')

23
src/router.js Normal file
View File

@ -0,0 +1,23 @@
import Discover from './views/Discover.vue'
import Feed from './views/Feed.vue'
import Router from 'vue-router'
import Vue from 'vue'
import { generateUrl } from '@nextcloud/router'
Vue.use(Router)
const router = new Router({
base: generateUrl('apps/repod'),
routes: [
{
path: '/',
component: Discover,
},
{
path: '/:url',
component: Feed,
},
],
})
export default router

View File

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

3
src/store/bus.js Normal file
View File

@ -0,0 +1,3 @@
import Vue from 'vue'
export const EventBus = new Vue()

17
src/store/main.js Normal file
View File

@ -0,0 +1,17 @@
import Vuex, { Store } from 'vuex'
import Vue from 'vue'
import { player } from './player.js'
import { settings } from './settings.js'
import { subscriptions } from './subscriptions.js'
Vue.use(Vuex)
const store = new Store({
modules: {
player,
settings,
subscriptions,
},
})
export default store

147
src/store/player.js Normal file
View File

@ -0,0 +1,147 @@
import { EventBus } from './bus.js'
import axios from '@nextcloud/axios'
import { decodeUrl } from '../utils/url.js'
import { formatEpisodeTimestamp } from '../utils/time.js'
import { generateUrl } from '@nextcloud/router'
import router from '../router.js'
import store from './main.js'
const audio = new Audio()
audio.ondurationchange = () => store.commit('player/duration', audio.duration)
audio.onended = () => store.dispatch('player/stop')
audio.onloadeddata = () => store.commit('player/loaded', true)
audio.onplay = () => store.dispatch('player/play')
audio.onpause = () => store.dispatch('player/pause')
audio.onratechange = () => store.commit('player/rate', audio.playbackRate)
audio.onseeked = () => store.commit('player/currentTime', audio.currentTime)
audio.ontimeupdate = () => store.commit('player/currentTime', audio.currentTime)
audio.onvolumechange = () => store.commit('player/volume', audio.volume)
export const player = {
namespaced: true,
state: {
currentTime: null,
duration: null,
episode: null,
loaded: false,
paused: null,
podcastUrl: null,
volume: 1,
rate: 1,
started: 0,
},
mutations: {
action: (state, action) => {
state.episode.action = action
if (action && action.position && action.position < action.total) {
audio.currentTime = action.position
state.started = audio.currentTime
}
},
currentTime: (state, currentTime) => {
state.currentTime = currentTime
},
duration: (state, duration) => {
state.duration = duration
},
episode: (state, episode) => {
state.episode = episode
if (episode) {
state.podcastUrl = decodeUrl(router.currentRoute.params.url)
audio.src = episode.url
audio.load()
audio.play()
if (
episode.action &&
episode.action.position &&
episode.action.position < episode.action.total
) {
audio.currentTime = episode.action.position
state.started = audio.currentTime
}
} else {
state.loaded = false
state.podcastUrl = null
audio.src = ''
}
},
loaded: (state, loaded) => {
state.loaded = loaded
},
paused: (state, paused) => {
state.paused = paused
},
volume: (state, volume) => {
state.volume = volume
},
rate: (state, rate) => {
state.rate = rate
},
started: (state, started) => {
state.started = started
},
},
actions: {
load: async (context, episode) => {
context.commit('episode', episode)
try {
const action = await axios.get(
generateUrl('/apps/repod/episodes/action?url={url}', {
url: episode.url,
}),
)
context.commit('action', action.data)
} catch {}
},
pause: (context) => {
audio.pause()
context.commit('paused', true)
context.dispatch('time')
},
play: (context) => {
audio.play()
context.commit('paused', false)
context.commit('started', audio.currentTime)
},
seek: (context, currentTime) => {
audio.currentTime = currentTime
context.dispatch('time')
},
stop: (context) => {
context.dispatch('pause')
context.commit('episode', null)
},
time: async (context) => {
const episode = context.state.episode
episode.action = {
podcast: context.state.podcastUrl,
episode: context.state.episode.url,
guid: context.state.episode.guid,
action: 'play',
timestamp: formatEpisodeTimestamp(new Date()),
started: Math.round(context.state.started),
position: Math.round(audio.currentTime),
total: Math.round(audio.duration),
}
axios.post(generateUrl('/apps/gpoddersync/episode_action/create'), [
episode.action,
])
EventBus.$emit('updateEpisodesList', episode)
},
volume: (_, volume) => {
audio.volume = volume
},
rate: (_, rate) => {
audio.playbackRate = rate
},
},
}
setInterval(() => {
if (player.state.paused === false) {
store.dispatch('player/time')
}
}, 40000)

View File

@ -1,167 +0,0 @@
import type { EpisodeActionInterface, EpisodeInterface } from '../utils/types.ts'
import { getCookie, setCookie } from '../utils/cookies.ts'
import axios from '@nextcloud/axios'
import { defineStore } from 'pinia'
import { formatEpisodeTimestamp } from '../utils/time.ts'
import { generateUrl } from '@nextcloud/router'
import { showError } from '../utils/toast.ts'
import { t } from '@nextcloud/l10n'
const audio = new Audio()
export const usePlayer = defineStore('player', {
state: () => ({
currentTime: null as number | null,
duration: null as number | null,
episode: null as EpisodeInterface | null,
loaded: false,
paused: true,
playCount: 0,
podcastUrl: null as string | null,
volume: 1,
rate: 1,
started: 0,
}),
actions: {
init() {
audio.playbackRate = parseFloat(getCookie('repod.rate') || '1')
audio.volume = parseFloat(getCookie('repod.volume') || '1')
audio.ondurationchange = () => (this.duration = audio.duration)
audio.onended = () => this.stop()
audio.onloadeddata = () => (this.loaded = true)
audio.onpause = () => this.pause()
audio.onplay = () => this.play()
audio.onratechange = () => (this.rate = audio.playbackRate)
audio.onseeked = () => (this.currentTime = audio.currentTime)
audio.ontimeupdate = () => (this.currentTime = audio.currentTime)
audio.onvolumechange = () => (this.volume = audio.volume)
setInterval(this.act, 40000)
setInterval(this.conflict, 1000)
},
act() {
if (this.paused === false) {
this.time()
}
},
conflict() {
this.playCount = 0
},
metadatas(episode: EpisodeInterface) {
// https://developer.mozilla.org/en-US/docs/Web/API/Media_Session_API
navigator.mediaSession.metadata = new MediaMetadata({
title: episode.name,
artist: episode.author,
album: episode.title,
artwork: episode.image ? [{ src: episode.image }] : [],
})
navigator.mediaSession.setActionHandler('play', this.play)
navigator.mediaSession.setActionHandler('pause', this.pause)
navigator.mediaSession.setActionHandler('stop', this.stop)
navigator.mediaSession.setActionHandler(
'seekbackward',
(details: MediaSessionActionDetails) =>
this.seek(audio.currentTime - (details.seekOffset || 10)),
)
navigator.mediaSession.setActionHandler(
'seekforward',
(details: MediaSessionActionDetails) =>
this.seek(audio.currentTime + (details.seekOffset || 30)),
)
navigator.mediaSession.setActionHandler(
'seekto',
(details: MediaSessionActionDetails) =>
!details.fastSeek && this.seek(details.seekTime || 0),
)
},
async load(episode: EpisodeInterface | null, podcastUrl?: string) {
this.episode = episode
this.podcastUrl = podcastUrl || null
if (this.episode?.url) {
audio.src = this.episode.url
audio.load()
try {
const action = await axios.get<EpisodeActionInterface>(
generateUrl('/apps/repod/episodes/action?url={url}', {
url: this.episode.url,
}),
)
this.episode.action = action.data
} catch {}
if (
this.episode.action
&& this.episode.action.position < this.episode.action.total
) {
audio.currentTime = this.episode.action.position
this.started = audio.currentTime
}
audio.play()
this.metadatas(this.episode)
} else {
this.loaded = false
this.podcastUrl = null
navigator.mediaSession.metadata = null
audio.src = ''
}
},
pause() {
audio.pause()
this.paused = true
this.time()
},
play() {
this.playCount++
if (this.playCount > 10) {
showError(t('repod', 'A browser extension conflict with RePod'))
} else {
audio.play()
this.paused = false
this.started = audio.currentTime
}
},
seek(currentTime: number) {
audio.currentTime = currentTime
this.time()
},
stop() {
this.pause()
this.episode = null
},
time() {
if (!this.podcastUrl || !this.episode?.url) {
return
}
this.episode.action = {
podcast: this.podcastUrl,
episode: this.episode.url,
guid: this.episode.guid,
action: 'play',
timestamp: formatEpisodeTimestamp(new Date()),
started: Math.round(this.started),
position: Math.round(audio.currentTime),
total: Math.round(audio.duration),
}
axios.post(generateUrl('/apps/gpoddersync/episode_action/create'), [
this.episode.action,
])
},
setVolume(volume: number) {
audio.volume = volume
setCookie('repod.volume', volume.toString(), 365)
},
setRate(rate: number) {
audio.playbackRate = rate
setCookie('repod.rate', rate.toString(), 365)
},
},
})

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

@ -0,0 +1,28 @@
import { getCookie, setCookie } from '../utils/cookies.js'
export const settings = {
namespaced: true,
state: {
filters: {
listened: true,
listening: true,
unlistened: true,
},
},
mutations: {
filters: (state, filters) => {
state.filters = { ...state.filters, ...filters }
setCookie('repod.filters', JSON.stringify(state.filters), 365)
},
},
actions: {
fetch: (context) => {
try {
const filters = getCookie('repod.filters')
context.commit('filters', JSON.parse(filters))
} catch (e) {
// nothing
}
},
},
}

View File

@ -1,36 +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') || '{}') || {}
return {
filters: {
listened:
filters.listened === undefined ? true : filters.listened,
listening:
filters.listening === undefined ? true : filters.listening,
unlistened:
filters.unlistened === undefined ? true : 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,28 @@
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
export const subscriptions = {
namespaced: true,
state: {
subscriptions: [],
},
mutations: {
set: (state, subscriptions) => {
state.subscriptions = subscriptions
},
},
actions: {
fetch: async (context) => {
const metrics = await axios.get(
generateUrl('/apps/gpoddersync/personal_settings/metrics'),
)
const subs = [...metrics.data.subscriptions].sort(
(a, b) => b.listenedSeconds - a.listenedSeconds,
)
context.commit(
'set',
subs.map((sub) => sub.url),
)
},
},
}

View File

@ -1,63 +0,0 @@
import type {
PersonalSettingsMetricsInterface,
PodcastDataInterface,
PodcastMetricsInterface,
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: SubscriptionInterface) => 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: PodcastMetricsInterface, b: PodcastMetricsInterface) =>
b.listenedSeconds - a.listenedSeconds,
)
.map((sub: PodcastMetricsInterface) => ({
metrics: sub,
isFavorite: favorites.includes(sub.url),
data: this.getSubByUrl(sub.url)?.data,
}))
},
addMetadatas(link: string, data: PodcastDataInterface) {
this.subs = this.subs.map((sub: SubscriptionInterface) =>
sub.metrics.url === link ? { ...sub, data } : sub,
)
},
setFavorite(link: string, isFavorite: boolean) {
this.subs = this.subs.map((sub: SubscriptionInterface) =>
sub.metrics.url === link ? { ...sub, isFavorite } : sub,
)
setCookie(
'repod.favorites',
JSON.stringify(
this.subs
.filter((sub: SubscriptionInterface) => sub.isFavorite)
.map((sub: SubscriptionInterface) => sub.metrics.url),
),
365,
)
},
},
})

View File

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

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

9
src/utils/size.js Normal file
View File

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

View File

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

View File

@ -1,31 +0,0 @@
import { durationToSeconds, formatEpisodeTimestamp } from './time'
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)
export const markAs = (episode: EpisodeInterface, read: boolean, url: string) => {
episode.action = {
podcast: 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 || ''),
}
return episode
}

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

@ -0,0 +1,7 @@
// https://stackoverflow.com/a/5002618
export const cleanHtml = (text) => {
const pre = document.createElement('pre')
pre.innerHTML = text.replace(/<br\s*\/?>/gm, '\n')
const strippedText = pre.textContent || pre.innerText || ''
return strippedText.replace(/\n/gm, '<br>')
}

View File

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

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,75 +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
author?: string
description?: string
fetchedAtUnix: number
guid: string
type?: string
size?: number
pubDate?: {
date: string
timezone_type: number
timezone: string
}
duration?: string
action?: EpisodeActionInterface
selected?: boolean
}
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
}

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

@ -0,0 +1,3 @@
export const encodeUrl = (url) => encodeURIComponent(btoa(url))
export const decodeUrl = (url) => atob(decodeURIComponent(url))
export const toUrl = (url) => `/${encodeUrl(url)}`

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