Compare commits
558 Commits
Author | SHA1 | Date | |
---|---|---|---|
8cb58fe388 | |||
a9be73b88d | |||
0c26182721 | |||
e439730a4d | |||
f0a2d199f9 | |||
8fe01c2222 | |||
03baaa9875 | |||
d455385382 | |||
|
08ce645960 | ||
a2e14d65f2 | |||
f247b83b90 | |||
|
c7f07e4562 | ||
e4f0ae8f83 | |||
0648f90768 | |||
|
0514ce24ce | ||
|
70bd8c191c | ||
c5b3434b31 | |||
|
921b14b2d3 | ||
f79a19116d | |||
34ad127a0b | |||
697586e4de | |||
|
98011122b5 | ||
|
a4f2151ebe | ||
|
6b44189f07 | ||
b606a239bc | |||
|
9823a1eadf | ||
d11f8d41c0 | |||
de52f111c5 | |||
407c93ae49 | |||
17ef77ea6e | |||
|
20a9a9f14d | ||
676b1e0ea2 | |||
0edf8d2634 | |||
56c9102284 | |||
75aad7baab | |||
668c377c33 | |||
3aa5a6020e | |||
5b0b1dcc81 | |||
4f412c23c0 | |||
c34646b72c | |||
43692a233c | |||
6e289b02b4 | |||
5a58b253d2 | |||
7b4c39499a | |||
427c6b2ee8 | |||
cbe8750fd2 | |||
|
bc6c22e3c5 | ||
785e6c7832 | |||
b9f254f561 | |||
|
b182f7e752 | ||
ee48860a47 | |||
64dcfe36fe | |||
1a65befeed | |||
082964d4e2 | |||
3a503e1969 | |||
0024507ed5 | |||
3232723602 | |||
c3d6319402 | |||
|
bf40156f68 | ||
2dd9578257 | |||
7cd45d3539 | |||
8fd3eada75 | |||
|
3ebfbf6df7 | ||
133a8e8a09 | |||
|
d00e72439e | ||
4108ae57bc | |||
|
92ad21d49b | ||
761b3f1079 | |||
fb586e7115 | |||
|
11e0e0e675 | ||
|
f2de304d60 | ||
cf298db3d7 | |||
|
749fc6e628 | ||
bfebf4996f | |||
|
2a88b0d392 | ||
b8035a21ee | |||
a51fefb321 | |||
f1442c6c5f | |||
13515c3141 | |||
|
d53f9216c5 | ||
|
f4093378a9 | ||
|
6992ce6e50 | ||
|
f6630f91d8 | ||
70184c79b0 | |||
|
9e934e6ca6 | ||
0d017bbc68 | |||
|
3b2981d201 | ||
25949e98c9 | |||
b002e3bab8 | |||
d80044934f | |||
|
4d88f485fb | ||
|
ae66036a82 | ||
674f0193db | |||
ccc903be97 | |||
10c7a1f907 | |||
a6ab9c69d9 | |||
285e9f7dfb | |||
60fc620b79 | |||
5588bb93e3 | |||
b97d4487bb | |||
29c29cdfdd | |||
d7c3b87d8d | |||
9fa48c2da3 | |||
3cea8d3505 | |||
23280d68b9 | |||
1e1bb03c23 | |||
fc86f62d93 | |||
83e3358e9b | |||
38bc986bb3 | |||
eae106e72b | |||
3c358a3c5c | |||
|
79ee855f9b | ||
|
5150cb6501 | ||
1d85811ad3 | |||
872b0ced0a | |||
|
2a280c3493 | ||
|
c9f922b31d | ||
c983ab8d3b | |||
4f0685ccbd | |||
e165a070c8 | |||
|
622f5ec635 | ||
|
7d64e3370c | ||
fcf99e5bbf | |||
924106202a | |||
ce2412fb01 | |||
6c348d5583 | |||
|
7eef3ceaf0 | ||
|
5321c0a3bf | ||
fe40b7c9f7 | |||
|
11a1db72c6 | ||
7f00696140 | |||
|
33dcbe9162 | ||
5c6542e60c | |||
|
ea6704c537 | ||
df4ac80554 | |||
2b2ab2af8f | |||
|
7b00eb22ff | ||
|
ecee6ff2a8 | ||
57a22d9390 | |||
42c35d3856 | |||
4d199bc02b | |||
|
8a89cc06cd | ||
|
03740231c5 | ||
b54ab2be91 | |||
d1658a9408 | |||
1eb8b35501 | |||
fb7780fead | |||
19c56ef31d | |||
2bad1852d7 | |||
0302489924 | |||
81cb6a0191 | |||
094b7812cd | |||
491ad89242 | |||
01e2dabb65 | |||
a86ea6ab3f | |||
7b7ceef503 | |||
437c7868dd | |||
7c151d8f58 | |||
b7025a7aa1 | |||
42035d6e18 | |||
5ed33d1cf6 | |||
4e4730efd5 | |||
9005b519f3 | |||
b0132287f0 | |||
062da25264 | |||
fd0f8f9c7a | |||
|
35610ee8da | ||
60aedf3be5 | |||
4bafb3306b | |||
58af1f6d40 | |||
|
4eb9236ecb | ||
eb1196c841 | |||
dd275a1f03 | |||
f205d3243f | |||
46b30f1ebb | |||
9be107edc9 | |||
4e6eee96bf | |||
ddef81f92f | |||
456592adfc | |||
|
147311013c | ||
|
99dc3582c4 | ||
2824431330 | |||
a30678bfd2 | |||
6bc8d70016 | |||
c42d455d4b | |||
5b08cf970e | |||
cf6dd25378 | |||
a2c3b389ba | |||
5917c59610 | |||
|
6cf868dc51 | ||
66b59c52fa | |||
36c3bd875b | |||
5742d1a762 | |||
bd9e3691b9 | |||
19427809ca | |||
544c91edee | |||
cf6bd440bf | |||
891d4762d0 | |||
a2b63241cc | |||
5db93914d2 | |||
68ada2b0e0 | |||
c91c17ae66 | |||
b4160d23d2 | |||
caf0bb7ec0 | |||
5f528e6b9b | |||
196bc23b1a | |||
e78e3b2565 | |||
b67123b578 | |||
53543a259f | |||
7efb0327d4 | |||
81af0c219f | |||
1feb0291bb | |||
989d5d38e1 | |||
b4ccd98f77 | |||
75da02e05b | |||
1530e8b294 | |||
7e359bdf29 | |||
082161e177 | |||
04ed6b101a | |||
6456ccc3d0 | |||
e190a9eeb6 | |||
e63ff6ef04 | |||
a50947ffaf | |||
c97e927a15 | |||
a6a0f0d4f8 | |||
358c1d06c8 | |||
1e452263a7 | |||
|
bfa9cde661 | ||
0238107be2 | |||
f94b529d6d | |||
1302c82fdb | |||
|
08b327e408 | ||
2104e2e6d8 | |||
|
c73e53d04e | ||
cee28f51a6 | |||
|
1665e1e193 | ||
1d73de5925 | |||
|
a753d1d634 | ||
a91648848b | |||
a538b38160 | |||
8479cc1553 | |||
69a2c20338 | |||
55ee2634df | |||
f3090c5490 | |||
d57189490e | |||
d5c34fa719 | |||
|
57b581fa54 | ||
|
74fa105d5a | ||
d81e6b505c | |||
|
ef66427afc | ||
f9b0c8fa39 | |||
b8466aebf1 | |||
|
1aa6dad3ba | ||
|
0c0946559a | ||
fcf389f925 | |||
|
6c59851cb9 | ||
1d15ade42e | |||
|
c85a347032 | ||
ec605fde0a | |||
46b7d80652 | |||
541c669dd5 | |||
79c6fbcc16 | |||
42bfdb530b | |||
bc8db22fce | |||
|
a4a00d4aea | ||
8d13fac7aa | |||
|
83e519d4c5 | ||
7ba27d7c9c | |||
5d75b51c63 | |||
a60341f352 | |||
c27f358f71 | |||
691ee72ac5 | |||
7a4bbd8e47 | |||
a53bab8be5 | |||
93b3cdbdc0 | |||
46eb48e81e | |||
8ee01ed80f | |||
010550fa60 | |||
de1b7184d2 | |||
e7d74d5378 | |||
a2c96f746b | |||
f5a8e09d52 | |||
228fb82d5d | |||
51c21bf4d6 | |||
|
57c455e77a | ||
2364e83fb6 | |||
4478c07f0c | |||
|
fe2df167e0 | ||
eb427681fb | |||
473d181807 | |||
176760cf3b | |||
5c117de552 | |||
a747286341 | |||
c46f4f1c61 | |||
d64746be0b | |||
aa3c782005 | |||
41c577f5cb | |||
|
176874f979 | ||
|
c6ab2ff692 | ||
0825598c36 | |||
2c078d32ae | |||
|
402f167206 | ||
ef064f0eb3 | |||
|
bd905cae1c | ||
d0142b01dd | |||
fc8c8f23c2 | |||
|
cc149c2b4f | ||
|
3af343910e | ||
5d20fb5dd3 | |||
3da2da7e98 | |||
c26599cdf7 | |||
|
dae875140d | ||
601f77d6a3 | |||
25bddc5b31 | |||
|
3bd282a0c0 | ||
bac5a5114f | |||
|
0e16892874 | ||
9bf5b9c653 | |||
|
2b7f43fb5c | ||
b0a0414fd4 | |||
cbc9654af8 | |||
e6621fee04 | |||
|
e55dc689f6 | ||
|
dbd6816504 | ||
7b5330e146 | |||
|
7492baad4e | ||
af25bc191c | |||
|
b1df2759d3 | ||
e25b384108 | |||
|
b11999dab8 | ||
b55fe53304 | |||
8a421ebc8f | |||
|
742ca212ef | ||
|
e96c494421 | ||
e7e03ca42d | |||
|
4a3da45d03 | ||
ee22ac2726 | |||
|
f389c225c4 | ||
5a6427f8a1 | |||
|
8058f328bf | ||
e5beb6ef73 | |||
9178670194 | |||
e96291d9a2 | |||
867afbe4df | |||
bebd6bf5ed | |||
522c929a82 | |||
a2e160afcb | |||
8d7c44601c | |||
|
a40b10171a | ||
00f33e64b8 | |||
90f75b10e6 | |||
d3c682988c | |||
|
48e205d479 | ||
|
78a682acf4 | ||
0cb93cdb72 | |||
|
d1b83c8910 | ||
4e0253b383 | |||
10af8f87fb | |||
|
ce7be518dc | ||
|
1083b723b3 | ||
227b3eb938 | |||
3ec978be7e | |||
9e3043f0c8 | |||
6b30b2b507 | |||
988069b6a6 | |||
ffe1335ce0 | |||
3dd7bc372b | |||
a6d2d36f8d | |||
185a8c8a7e | |||
6c406b5361 | |||
bdeb46a194 | |||
2c1b95c6bb | |||
ce0e6e06bc | |||
624c52c231 | |||
e52d20ffbc | |||
5a510016a7 | |||
9ccd237f80 | |||
ea62de33d0 | |||
746d39c775 | |||
972b4ea207 | |||
|
9a00971ce1 | ||
|
109d1087a4 | ||
209fffc305 | |||
9e216ca96f | |||
11e0abec67 | |||
|
57bd010ed0 | ||
|
8f68aa1485 | ||
26aeefa70d | |||
|
c31074457e | ||
243ae7a7ef | |||
9a411358ca | |||
33fee28c02 | |||
21760276cd | |||
|
7c5b542125 | ||
4909870d30 | |||
|
0d72ef82a2 | ||
d215081086 | |||
6e925cd607 | |||
add84b935c | |||
c6f06a9f29 | |||
a16556c0eb | |||
6ce5c0c199 | |||
c413e0cb1a | |||
2398c9749d | |||
ef1fa64047 | |||
302e8c781b | |||
2c4a6e3e56 | |||
5f80d3cb3e | |||
ddcca82979 | |||
b2ff2733a0 | |||
09a19505d4 | |||
|
604c9ed3f0 | ||
|
d1dd708574 | ||
d9df7de1a7 | |||
d808ac0609 | |||
abf73c0ce9 | |||
1dba881a0e | |||
3ff5d636d5 | |||
6b9ee6af5d | |||
a627ebbf54 | |||
|
02171b80c7 | ||
d5d91b3788 | |||
|
dcfdfe52ea | ||
31064c84a3 | |||
4687b5e00c | |||
bc42b08a25 | |||
b04d516b05 | |||
f00d3b4b25 | |||
6df4813944 | |||
9d7d08f0dc | |||
d4c2c4e49d | |||
9b2d245e80 | |||
1c261fbe4b | |||
9fa69dfbb8 | |||
ac4161a18a | |||
99fa123640 | |||
87315b1221 | |||
b63e1175a0 | |||
896fbf3e75 | |||
10269aff99 | |||
|
7faead8d63 | ||
e37c16e2df | |||
78560b6239 | |||
70a8b83591 | |||
|
e43f7fa1e3 | ||
|
7d61dec632 | ||
3b0f5bc852 | |||
|
62b8829ee5 | ||
4093b4922c | |||
1a3c27eed1 | |||
6e2e7d7c59 | |||
0fbad1570f | |||
|
7deb50a9e5 | ||
df7c9e8e25 | |||
|
34cdb498eb | ||
7a2d924e6b | |||
5c2e1a5dd6 | |||
7843e930d5 | |||
22ef756a2d | |||
ff23be4f91 | |||
9f9fdf4cf0 | |||
387100e928 | |||
ccb1c72ad3 | |||
2934935886 | |||
a5289651bd | |||
4da4e6a3a1 | |||
a480d70faf | |||
e2b4cb546f | |||
676a91f69d | |||
b40f480975 | |||
46cd766cac | |||
751c3c1e01 | |||
c28abc7564 | |||
2ed16a316e | |||
3b460f620b | |||
c951a93b8c | |||
d6a9eb0c31 | |||
409bd429eb | |||
8045dadf9f | |||
b69516031a | |||
f26c7fcc70 | |||
c63176db9b | |||
606a769246 | |||
2047470555 | |||
a90368fa2e | |||
37d264d717 | |||
b784040b69 | |||
5f0b43c26f | |||
87294d1881 | |||
c101588eaf | |||
ce5408395d | |||
f4fc4b52e3 | |||
9dc09c2185 | |||
1d65481be8 | |||
9ebd96cec1 | |||
31bf193543 | |||
2d6f2cb5e7 | |||
c399a20de3 | |||
e29f735ca4 | |||
7137d2cd34 | |||
46beb6ebfa | |||
01ecab3cb5 | |||
28ff127a86 | |||
5ef0a9612a | |||
25c6c515e0 | |||
61f865da52 | |||
540c5df7e5 | |||
c48432c0b5 | |||
efe0525fce | |||
18425ba9b5 | |||
f17e795004 | |||
80844d8ac0 | |||
73068b1867 | |||
66197f24fc | |||
314b2c449c | |||
1b1aac6f3d | |||
d6e7c5a460 | |||
5a03bf7d6e | |||
140fff8e3a | |||
c14dceea63 | |||
b5007e672e | |||
84f23d64d3 | |||
a23805c65f | |||
950ae269c9 | |||
861ecf0db1 | |||
b389200017 | |||
d59beae91c | |||
515f00fffc | |||
bba6803b5b | |||
ea8567169d | |||
759814c6af | |||
818124534b | |||
6b6d21fa2f | |||
1c566b19cb | |||
47e44bdaf5 | |||
|
162db87759 | ||
822bf92a47 | |||
9137526abc | |||
93898d3309 | |||
a5f219d363 | |||
754a14b099 | |||
c1e15299ec | |||
2a519e4543 | |||
3d066d63c6 | |||
7375088700 | |||
b2f2a96f2b | |||
767a6e9bac | |||
a264ac2066 | |||
f89440005e | |||
0c2154a2ab | |||
615f9e0c49 | |||
0bbe6dbf24 | |||
af30767ab6 | |||
9b932437fc | |||
e478372b47 | |||
2bb37caa79 | |||
23bea86ed6 | |||
138cf6d42e |
@ -1,8 +1,14 @@
|
||||
*.iml
|
||||
.idea
|
||||
/.php-cs-fixer.cache
|
||||
/.php_cs.cache
|
||||
/build/
|
||||
/.idea/
|
||||
/*.iml
|
||||
|
||||
/vendor/
|
||||
js/
|
||||
node_modules/
|
||||
/vendor-bin/*/vendor/
|
||||
|
||||
/.php-cs-fixer.cache
|
||||
/tests/.phpunit.cache
|
||||
|
||||
/node_modules/
|
||||
/js/
|
||||
/css/
|
||||
|
||||
/build/
|
||||
|
@ -1,9 +0,0 @@
|
||||
*.iml
|
||||
.idea
|
||||
/.php-cs-fixer.cache
|
||||
/.php_cs.cache
|
||||
/build/
|
||||
/vendor/
|
||||
js/
|
||||
node_modules/
|
||||
l10n/
|
15
.eslintrc.cjs
Normal file
15
.eslintrc.cjs
Normal file
@ -0,0 +1,15 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
'@nextcloud',
|
||||
'@vue/eslint-config-typescript/recommended',
|
||||
'plugin:pinia/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
parser: 'vue-eslint-parser',
|
||||
rules: {
|
||||
'jsdoc/require-jsdoc': 'off',
|
||||
'vue/first-attribute-linebreak': 'off',
|
||||
'sort-imports': 'error',
|
||||
'vue/attributes-order': ['error', { alphabetical: true }],
|
||||
},
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
'@nextcloud',
|
||||
],
|
||||
rules: {
|
||||
'sort-imports': 'error',
|
||||
'vue/attributes-order': ['error', { alphabetical: true }],
|
||||
},
|
||||
}
|
1
.gitattributes
vendored
1
.gitattributes
vendored
@ -1 +0,0 @@
|
||||
/js/* binary
|
@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
php:
|
||||
runs-on: ubuntu-latest
|
||||
container: nextcloud:26
|
||||
container: nextcloud:30
|
||||
steps:
|
||||
- run: apt-get update
|
||||
- run: apt-get install -y git nodejs
|
||||
@ -24,47 +24,52 @@ jobs:
|
||||
- run: composer install
|
||||
- run: composer run lint
|
||||
- run: composer run cs:check
|
||||
- run: composer run psalm:check
|
||||
- run: composer run psalm
|
||||
|
||||
nodejs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: skjnldsv/read-package-engines-version-actions@v2
|
||||
id: versions
|
||||
with:
|
||||
fallbackNode: '^20.0.0'
|
||||
fallbackNpm: '^9.0.0'
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ steps.versions.outputs.nodeVersion }}
|
||||
- run: npm i -g npm@${{ steps.versions.outputs.npmVersion }}
|
||||
node-version: "^20"
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npm run stylelint
|
||||
- run: npm run build
|
||||
|
||||
release:
|
||||
if: startsWith(gitea.ref, 'refs/tags')
|
||||
if: gitea.ref_type == 'tag'
|
||||
runs-on: ubuntu-latest
|
||||
container: nextcloud:26
|
||||
container: nextcloud:30
|
||||
steps:
|
||||
- run: apt-get update
|
||||
- run: apt-get install -y git nodejs
|
||||
- uses: actions/checkout@v4
|
||||
- run: curl -sSLo /usr/local/bin/composer https://getcomposer.org/download/latest-stable/composer.phar
|
||||
- run: chmod +x /usr/local/bin/composer
|
||||
- uses: skjnldsv/read-package-engines-version-actions@v2
|
||||
id: versions
|
||||
with:
|
||||
fallbackNode: '^20.0.0'
|
||||
fallbackNpm: '^9.0.0'
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ steps.versions.outputs.nodeVersion }}
|
||||
- run: npm i -g npm@${{ steps.versions.outputs.npmVersion }}
|
||||
node-version: "^20"
|
||||
- run: make dist
|
||||
- uses: akkuman/gitea-release-action@v1
|
||||
with:
|
||||
files: |
|
||||
build/artifacts/RePod.tar.gz
|
||||
build/artifacts/repod.tar.gz
|
||||
- uses: FKLC/sign-files-action@v1.0.0
|
||||
with:
|
||||
privateKey: ${{ secrets.PRIVATEKEY }}
|
||||
algorithm: RSA-SHA512
|
||||
extension: .sig
|
||||
outputFolder: build/artifacts
|
||||
files: |
|
||||
build/artifacts/repod.tar.gz
|
||||
- id: sign
|
||||
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/Xefir/repod/releases/download/<<<${{ gitea.ref_name }}>>>/repod.tar.gz", "signature": "<<<${{ steps.sign.outputs.SIGNATURE }}>>>" }'
|
||||
|
20
.gitignore
vendored
20
.gitignore
vendored
@ -1,8 +1,14 @@
|
||||
*.iml
|
||||
.idea
|
||||
/.php-cs-fixer.cache
|
||||
/.php_cs.cache
|
||||
/build/
|
||||
/.idea/
|
||||
/*.iml
|
||||
|
||||
/vendor/
|
||||
js/
|
||||
node_modules/
|
||||
/vendor-bin/*/vendor/
|
||||
|
||||
/.php-cs-fixer.cache
|
||||
/tests/.phpunit.cache
|
||||
|
||||
/node_modules/
|
||||
/js/
|
||||
/css/
|
||||
|
||||
/build/
|
||||
|
16
.l10nignore
16
.l10nignore
@ -1,8 +1,14 @@
|
||||
.idea/
|
||||
*.iml
|
||||
.idea
|
||||
/.php-cs-fixer.cache
|
||||
/.php_cs.cache
|
||||
build/
|
||||
|
||||
vendor/
|
||||
js/
|
||||
vendor-bin/*/vendor/
|
||||
|
||||
.php-cs-fixer.cache
|
||||
tests/.phpunit.cache
|
||||
|
||||
node_modules/
|
||||
js/
|
||||
css/
|
||||
|
||||
build/
|
||||
|
@ -15,7 +15,6 @@ class MyConfig extends Config
|
||||
$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;
|
||||
}
|
||||
}
|
||||
@ -23,10 +22,11 @@ class MyConfig extends Config
|
||||
$config = new MyConfig();
|
||||
$config
|
||||
->getFinder()
|
||||
->ignoreVCSIgnored(true)
|
||||
->notPath('build')
|
||||
->notPath('l10n')
|
||||
->notPath('node_modules')
|
||||
->notPath('src')
|
||||
->notPath('vendor')
|
||||
->in(__DIR__);
|
||||
|
||||
return $config;
|
||||
|
340
CHANGELOG.md
Normal file
340
CHANGELOG.md
Normal file
@ -0,0 +1,340 @@
|
||||
## 3.3.2 - What a Nightmare - 2024-10-24
|
||||
|
||||
### Fixed
|
||||
- 🚑 Revert [#178](https://git.crystalyx.net/Xefir/repod/issues/178) not working on big subscriptions lists
|
||||
[#182](https://git.crystalyx.net/Xefir/repod/issues/182) reported by @SteveDinn
|
||||
|
||||
## 3.3.1 - Breaking the Loop - 2024-10-24
|
||||
|
||||
### Changed
|
||||
- ⚡ Speed up the loading time of subscriptions
|
||||
[#178](https://git.crystalyx.net/Xefir/repod/issues/178) reported by @MikeAndrews
|
||||
|
||||
### Fixed
|
||||
- 🐛 Prevent Firefox for going nuts when having Plasma Integration addon installed
|
||||
[#164](https://git.crystalyx.net/Xefir/repod/issues/164) reported by @cichy1173, @Share1440 and @mark.collins
|
||||
|
||||
## 3.3.0 - Into The Jet Lag - 2024-10-18
|
||||
|
||||
### Changed
|
||||
- 🧑💻 CSS isn't mixed in the main JS file anymore
|
||||
|
||||
### Fixed
|
||||
- 🐛 App won't load on Firefox 115
|
||||
[#158](https://git.crystalyx.net/Xefir/repod/issues/158) reported by @Jaunty and @mark.collins
|
||||
- 🔇 Volume slider didn't work properly
|
||||
|
||||
### Deprecated
|
||||
- 💣 Require Nextcloud 29 or more
|
||||
|
||||
## 3.2.0 - Typing fast - 2024-09-15
|
||||
|
||||
### Added
|
||||
- 📝 Add Cardo to list of compatible clients
|
||||
[#176](https://github.com/thrillfall/nextcloud-gpodder/pull/176) reported by @n0vella
|
||||
|
||||
### Changed
|
||||
- 🧑💻 Switch entiere project to TypeScript
|
||||
|
||||
### Fixed
|
||||
- 💄 Missing icon on home when aren't any favorites
|
||||
- 💄 Tweaks spacing in several spaces on Home and banners
|
||||
- 💩 Leverage the available space between the episode title and the play button (but hacky way for now)
|
||||
[#59](https://git.crystalyx.net/Xefir/repod/issues/59#issuecomment-6246) reported by @W_LL_M
|
||||
|
||||
## 3.1.0 - Above the stars - 2024-09-02
|
||||
|
||||
### Added
|
||||
- ⭐ You can now add favorites subscriptions !
|
||||
It will show's up on the homepage instead of the recommendations witch appear only when you add a new subscription.
|
||||
[#59](https://git.crystalyx.net/Xefir/repod/issues/59) suggested by @W_LL_M, @Jaunty and @Satalink
|
||||
|
||||
### Changed
|
||||
- 💥 Use html5 routing instead of hashes. All the URLs has changed removing the `#/` part.
|
||||
|
||||
### Fixed
|
||||
- 🐛 Regression on 3.0 that prevent seeking player to episode last listened position
|
||||
[#136](https://git.crystalyx.net/Xefir/repod/issues/136) reported by @randomuser1967
|
||||
- ⚡ Improve the detection off mis-installed or mis-enabled gpodder app
|
||||
|
||||
## 3.0.0 - What a vue - 2024-08-17
|
||||
|
||||
### Added
|
||||
- 🌐 Add german translation
|
||||
Thanks to @OiledAmoeba [#120](https://git.crystalyx.net/Xefir/repod/issues/120)
|
||||
|
||||
### Changed
|
||||
- 🎉 Migrate to Vue 3
|
||||
- 🔖 Support Nextcloud 30
|
||||
- 🏗️ Switch from Vuex to Pinia
|
||||
|
||||
### Fixed
|
||||
- 💄 Use iTunes image first for episode if available
|
||||
- 💄 Displaying styles and proper HTML on episode's modal descriptions
|
||||
|
||||
### Removed
|
||||
- 🗑️ Temporary replacing @nextcloud/dialogs to toastyjs
|
||||
|
||||
## 2.3.3 - The Cake is a Lie - 2024-06-14
|
||||
|
||||
### Changed
|
||||
- ⬆️ Update @nextcloud/dialogs to 5.3.2
|
||||
|
||||
### Fixed
|
||||
- 🐛 App crashed when no cache system available
|
||||
[#107](https://git.crystalyx.net/Xefir/repod/issues/107) reported by @skvaller and @PhilTraere
|
||||
|
||||
## 2.3.2 - Young Youth - 2024-05-31
|
||||
|
||||
### Fixed
|
||||
- 🐛 New subscribe button on search not disapearing if subscribed
|
||||
- ♿ Missing accessibility label on this button as well
|
||||
|
||||
## 2.3.1 - Powerwash the Universe - 2024-05-29
|
||||
|
||||
### Changed
|
||||
- ⚡ Reduce app size by not shipping sourcemap
|
||||
|
||||
## 2.3.0 - Star Align - 2024-05-29
|
||||
|
||||
### Added
|
||||
- ➕ Ability to subscribe to podcast from search list
|
||||
[#105](https://git.crystalyx.net/Xefir/repod/issues/105) suggested by @crystalyz
|
||||
|
||||
### Changed
|
||||
- 🔖 Full support for Nextcloud 29
|
||||
- ⬆️ Update @nextcloud/vue to 8.12.0
|
||||
- 📄 Use only AGPL licence
|
||||
- ♻️ Refacto based on the new app template from Nextcloud devs
|
||||
|
||||
### Removed
|
||||
- 💀 Drop support for Nextcloud 26
|
||||
- ⚰️ Drop support for PHP 8.0
|
||||
- 🌐 Removed babel
|
||||
|
||||
## 2.2.1 - Shami was here - 2024-05-18
|
||||
|
||||
### Removed
|
||||
- ♻️ Rollback: Hide unreadable episodes because of insecure sources
|
||||
|
||||
## 2.2.0 - Moving in and out - 2024-05-18
|
||||
|
||||
### Added
|
||||
- 🚨 Linting the code with ESLint
|
||||
- 🎨 Prettierify the code
|
||||
|
||||
### Changed
|
||||
- ⬆️ Update all dependancies
|
||||
|
||||
### Fixed
|
||||
- 🔓 Hide unreadable episodes because of insecure sources
|
||||
|
||||
## 2.1.0 - Pocket Gundams - 2024-03-16
|
||||
|
||||
### Added
|
||||
- 🔍 Add CTA for rating the app on the store
|
||||
|
||||
### Changed
|
||||
- ⬆️ Update @nextcloud/dialogs to 5.2.0
|
||||
- ⬆️ Update @nextcloud/vue to 8.11.0
|
||||
- 🔖 Set compatibility with Nextcloud 29
|
||||
|
||||
### Fixed
|
||||
- 🔒 App wasn't working for non admin users
|
||||
[#76](https://git.crystalyx.net/Xefir/repod/issues/76) reported by @devasservice
|
||||
|
||||
## 2.0.0 - Taking Actions - 2024-03-05
|
||||
|
||||
### Added
|
||||
- 🍪 Saving filters preference
|
||||
[#66](https://git.crystalyx.net/Xefir/repod/issues/66) suggested by @jeef
|
||||
- 📋 Add several options on each episode :
|
||||
- Mark as read
|
||||
- Open the webpage of the episode
|
||||
- Download the episode
|
||||
- ↪️ Any actions will be reflected on the episode's list
|
||||
|
||||
### Changed
|
||||
- ⬆️ Update @nextcloud/vue to 8.8.1
|
||||
|
||||
### Fixed
|
||||
- ❤️🔥 Better handling ended episodes
|
||||
|
||||
## 1.5.9 - Just According to Keikaku - 2024-02-21
|
||||
|
||||
### Changed
|
||||
- 🧑💻 Change some endpoints to match gPodder.net "specifications"
|
||||
- ⬆️ Update @nextcloud/vue to 8.7.0
|
||||
|
||||
## 1.5.8 - Goblet of Eonothem - 2024-02-11
|
||||
|
||||
### Fixed
|
||||
- Fyyd API sometime send empty feeds, ignoring them
|
||||
|
||||
## 1.5.7 - 2024-02-08
|
||||
|
||||
### Removed
|
||||
- Proxy episodes when they are behind an unsecure http server
|
||||
|
||||
## 1.5.6 - 2024-02-07
|
||||
|
||||
### Added
|
||||
- Proxy episodes when they are behind an unsecure http server
|
||||
|
||||
### Changed
|
||||
- Update @nextcloud/vue to v8.6.2
|
||||
|
||||
## 1.5.5 - Hide and seek - 2024-02-04
|
||||
|
||||
### Fixed
|
||||
- Can't open podcast details if cache missing or misconfigured
|
||||
[#58](https://git.crystalyx.net/Xefir/repod/issues/58) reported by @raxventus
|
||||
|
||||
## 1.5.4 - In search of the truth - 2024-02-03
|
||||
|
||||
### Fixed
|
||||
- Nextcloud search engine didn't work on Nextcloud 26 and 27
|
||||
[#57](https://git.crystalyx.net/Xefir/repod/issues/57) reported by @JonOfUs
|
||||
|
||||
## 1.5.3 - The date where it all ends - 2024-02-01
|
||||
|
||||
### Changed
|
||||
- Update @nextcloud/vue to v8.6.1
|
||||
|
||||
### Fixed
|
||||
- Fix episode listing crashing if an invalid publication date is present in the RSS
|
||||
|
||||
## 1.5.2 - A little to the top - 2024-02-01
|
||||
|
||||
### Changed
|
||||
- Update @nextcloud/router to v3.0.0
|
||||
|
||||
### Fixed
|
||||
- Fix player alignment off by a couple of pixels
|
||||
|
||||
## 1.5.1 - Play on the PlayHead - 2024-01-30
|
||||
|
||||
### Changed
|
||||
- Update @nextcloud/vue to v8.6.0
|
||||
- Change the player progress bar to the native browser component
|
||||
[#52](https://git.crystalyx.net/Xefir/repod/issues/52) suggested by @W_LL_M
|
||||
|
||||
### Fixed
|
||||
- Force the placement of the filter settings to the top
|
||||
|
||||
## 1.5.0 - Featuring the filtering - 2024-01-30
|
||||
|
||||
### Added
|
||||
- Filtering options for each podcast section
|
||||
[#50](https://git.crystalyx.net/Xefir/repod/issues/50) suggested by @W_LL_M
|
||||
|
||||
### Changed
|
||||
- Update @nextcloud/router to v2.2.1
|
||||
- Update @nextcloud/dialogs to v5.1.1
|
||||
- Update @nextcloud/vue to v8.5.1
|
||||
- Update vue-material-deisgn-icons to v5.3.0
|
||||
- Displaying episode publication as "dd mmm yyyy" instead of xyz ago
|
||||
[#48](https://git.crystalyx.net/Xefir/repod/issues/48) suggested by @W_LL_M
|
||||
- Better display the reading status for episodes
|
||||
[#51](https://git.crystalyx.net/Xefir/repod/issues/51) suggested by @W_LL_M
|
||||
|
||||
### Fixed
|
||||
- When exporting feeds, if the RSS server fails, the export continue
|
||||
|
||||
### Removed
|
||||
- Remove @nextcloud/moment
|
||||
|
||||
## 1.4.4 - 2024-01-24
|
||||
|
||||
### Changed
|
||||
- Update @nextcloud/vue to v8.5.0
|
||||
|
||||
## 1.4.3 - 2024-01-21
|
||||
|
||||
### Added
|
||||
- Expose the feed URL
|
||||
[#41](https://git.crystalyx.net/Xefir/repod/issues/41) suggested by @SteveDinn
|
||||
|
||||
### Fixed
|
||||
- More granular playback speed adustment by steps of 0.1
|
||||
[#40](https://git.crystalyx.net/Xefir/repod/issues/40) reported by @SteveDinn
|
||||
|
||||
## 1.4.2 - 2024-01-20
|
||||
|
||||
### Fixed
|
||||
- Ended episodes still didn't show well, should be fixed now hopefully
|
||||
|
||||
## 1.4.1 - 2024-01-19
|
||||
|
||||
### Fixed
|
||||
- When deleting an episode on AntennaPod or on GPodder, it shows ended as expected
|
||||
- Add space in the bottom of tops to allow catching the scrollbar
|
||||
|
||||
## 1.4.0 - 2024-01-18
|
||||
|
||||
### Added
|
||||
- Import subscriptions
|
||||
- Export subscriptions
|
||||
|
||||
## 1.3.0 - 2024-01-18
|
||||
|
||||
### Added
|
||||
- Unified search integration
|
||||
|
||||
## 1.2.1 - 2024-01-18
|
||||
|
||||
### Changed
|
||||
- When click on the title of the podcast on the player bar, it now opens the description modal
|
||||
- Add icon on the playback speed setting
|
||||
|
||||
### Fixed
|
||||
- Better fix for the background color of player bar on light theme
|
||||
[#38](https://git.crystalyx.net/Xefir/repod/issues/38)
|
||||
|
||||
## 1.2.0 - 2024-01-17
|
||||
|
||||
### Added
|
||||
- Add a playback speed setting
|
||||
[#39](https://git.crystalyx.net/Xefir/repod/issues/39) suggested by @joezimjs
|
||||
|
||||
### Fixed
|
||||
- Duration wrongly displayed
|
||||
- Fix background color of player bar on light theme
|
||||
[#38](https://git.crystalyx.net/Xefir/repod/issues/38) reported by @joezimjs
|
||||
- Fix case of episodes not showing ended
|
||||
|
||||
## 1.1.2 - 2024-01-16
|
||||
|
||||
### Changed
|
||||
- Sort subscriptions by listened time
|
||||
|
||||
### Removed
|
||||
- Custom handler for redirections based on atom:link
|
||||
|
||||
## 1.1.1 - 2024-01-16
|
||||
|
||||
### Added
|
||||
- This changelog
|
||||
|
||||
### Fixed
|
||||
- An error occured if a duplicate is found in the search results or episodes list
|
||||
- Offline downloaded episode was shown as played and ended
|
||||
|
||||
## 1.1.0 - 2024-01-15
|
||||
|
||||
### Added
|
||||
- Show episode filesize on download description button
|
||||
- Horizontal scrollbars on hot and new podcasts
|
||||
|
||||
### Changed
|
||||
- Use moment instead of date-fns to have localized publication dates
|
||||
|
||||
### Fixed
|
||||
- Missing title on description modal
|
||||
- Display a generic avatar if author is missing
|
||||
- Handle feed's redirections
|
||||
- Sort search results and episodes by publication date
|
||||
|
||||
## 1.0.0 - 2024-01-11
|
||||
|
||||
- Initial release
|
23
Dockerfile
23
Dockerfile
@ -1,25 +1,22 @@
|
||||
FROM nextcloud:28
|
||||
FROM nextcloud:30
|
||||
|
||||
ENV NEXTCLOUD_UPDATE 1
|
||||
ENV NEXTCLOUD_ADMIN_USER repod
|
||||
ENV NEXTCLOUD_ADMIN_PASSWORD repod
|
||||
ENV NEXTCLOUD_INIT_HTACCESS 1
|
||||
ENV SQLITE_DATABASE repod
|
||||
ENV GPODDERSYNC_VERSION 3.8.2
|
||||
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 && \
|
||||
apt-get install -y nodejs npm sqlite3 && \
|
||||
rm -f /usr/local/etc/php/conf.d/opcache-recommended.ini && \
|
||||
/entrypoint.sh true
|
||||
|
||||
USER www-data
|
||||
|
||||
COPY --chown=www-data:www-data . apps/repod
|
||||
RUN curl -sSLo /tmp/gpoddersync.tar.gz https://github.com/thrillfall/nextcloud-gpodder/releases/download/${GPODDERSYNC_VERSION}/gpoddersync.tar.gz && \
|
||||
tar xvzf /tmp/gpoddersync.tar.gz -C apps && \
|
||||
rm /tmp/gpoddersync.tar.gz && \
|
||||
cd apps/repod && make build && cd - && \
|
||||
php occ app:enable gpoddersync repod && \
|
||||
RUN cd apps/repod && make build && cd - && \
|
||||
php occ app:install gpoddersync && \
|
||||
php occ app:enable repod && \
|
||||
php occ config:system:set debug --value=true
|
||||
|
||||
USER root
|
||||
|
661
LICENSE
Normal file
661
LICENSE
Normal file
@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
90
Makefile
90
Makefile
@ -1,3 +1,4 @@
|
||||
# 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.
|
||||
#
|
||||
@ -102,14 +103,14 @@ dist: build
|
||||
source:
|
||||
rm -rf $(source_build_directory)
|
||||
mkdir -p $(source_build_directory)
|
||||
tar cvzf $(source_package_name).tar.gz \
|
||||
tar -C .. -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
|
||||
@ -117,44 +118,43 @@ source:
|
||||
appstore:
|
||||
rm -rf $(appstore_build_directory)
|
||||
mkdir -p $(appstore_build_directory)
|
||||
tar cvzf $(appstore_package_name).tar.gz \
|
||||
--exclude-vcs \
|
||||
--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.config.js" \
|
||||
--exclude="../$(app_name)/stylelint.config.js" \
|
||||
--exclude="../$(app_name)/CHANGELOG.md" \
|
||||
--exclude="../$(app_name)/README.md" \
|
||||
--exclude="../$(app_name)/package-lock.json" \
|
||||
--exclude="../$(app_name)/LICENSES" \
|
||||
--exclude="../$(app_name)/src" \
|
||||
--exclude="../$(app_name)/stubs" \
|
||||
--exclude="../$(app_name)/vendor" \
|
||||
--exclude="../$(app_name)/translationfiles" \
|
||||
--exclude="../$(app_name)/babel.config.js" \
|
||||
--exclude="../$(app_name)/Dockerfile" \
|
||||
--exclude="../$(app_name)/psalm.xml" \
|
||||
--exclude="../$(app_name)/renovate.json" \
|
||||
../$(app_name)
|
||||
tar -C .. -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)/tsconfig.json" \
|
||||
--exclude="$(app_name)/stylelint.config.cjs" \
|
||||
--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)/screens" \
|
||||
--exclude="$(app_name)/vendor" \
|
||||
--exclude="$(app_name)/translationfiles" \
|
||||
--exclude="$(app_name)/Dockerfile" \
|
||||
--exclude="$(app_name)/psalm.xml" \
|
||||
--exclude="$(app_name)/renovate.json" \
|
||||
--exclude="$(app_name)/vite.config.ts" \
|
||||
$(app_name)
|
||||
|
||||
# Start a nextcloud server on Docker to kickstart developement
|
||||
.PHONY: dev
|
||||
|
58
README.md
58
README.md
@ -6,13 +6,50 @@ You need to have [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) inst
|
||||
|
||||
## Features
|
||||
|
||||
- [x] Browse podcasts and play them directly in Nextcloud
|
||||
- [x] Keep track of subscribed shows and episodes
|
||||
- [x] Sync them with GPodderSync compatible clients
|
||||
- [ ] Import and export subscriptions
|
||||
- [x] Mobile friendly interface
|
||||
- [ ] Unified search integration
|
||||
- [x] Interface in multiple languages
|
||||
- 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
|
||||
|
||||
## Comparaison with similar apps for Nextcloud
|
||||
|
||||
| | [RePod](https://apps.nextcloud.com/apps/repod) | [NextPod](https://apps.nextcloud.com/apps/nextpod) | [Music](https://apps.nextcloud.com/apps/music) | [Podcast](https://apps.nextcloud.com/apps/podcast) |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Actively maintened | ✅ | ✅ | ✅ | ❌ |
|
||||
| Play your local music files | ❌ | ❌ | ✅ | ❌ |
|
||||
| Sync with [GPodder clients](#clients-supporting-sync-of-gpoddersync) | ✅ | ✅ | ❌ | ❌ |
|
||||
| Add and manage subscriptions | ✅ | ❌ | ✅ | ✅ |
|
||||
| Listen synced episodes by another clients | ✅ | ✅ | ❌ | ❌ |
|
||||
| Fetch and listen new epidodes | ✅ | ❌ | ✅ | ✅ |
|
||||
| Keep track of listened episodes | ✅ | ✅ | ❌ | ✅ |
|
||||
| Download epidodes | ✅ | ✅ | ❌ | ✅ |
|
||||
| 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 | ⭕ (en/fr/de) | ❌ | ✅ | ⭕ (en/de) |
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Homepage
|
||||
![homepage](./screens/index.png)
|
||||
|
||||
### Discover
|
||||
![homepage](./screens/discover.png)
|
||||
|
||||
### Search
|
||||
![search](./screens/search.png)
|
||||
|
||||
### Episode list
|
||||
![episodes](./screens/episodes.png)
|
||||
|
||||
### Episode description
|
||||
![modal](./screens/modal.png)
|
||||
|
||||
## Clients supporting sync of GPodderSync
|
||||
|
||||
@ -21,10 +58,15 @@ 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.
|
||||
Either from the official Nextcloud [app store](https://apps.nextcloud.com/apps/repod) or by downloading the [latest release](https://git.crystalyx.net/Xefir/repod/releases/latest) and extracting it into your Nextcloud `apps/` directory.
|
||||
|
||||
## Known issues
|
||||
|
||||
- Conflict with Plasma Integration Firefox addon ([#164](https://git.crystalyx.net/Xefir/repod/issues/164))
|
||||
|
||||
## Credits
|
||||
|
||||
|
@ -4,28 +4,40 @@
|
||||
<id>repod</id>
|
||||
<name>RePod</name>
|
||||
<summary>🔊 Browse, manage and listen to podcasts</summary>
|
||||
<description><![CDATA[# Features
|
||||
<description><![CDATA[## Features
|
||||
- 🔍 Browse and subscribe huge collection of podcasts
|
||||
- 🔊 Listen to episodes directly in Nextcloud
|
||||
- 🌐 Sync your activity with [AntennaPod](https://antennapod.org/)
|
||||
- 🌐 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)
|
||||
|
||||
# Requirements
|
||||
## Requirements
|
||||
You need to have [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installed to use this app!]]></description>
|
||||
<version>1.0.0</version>
|
||||
<version>3.3.2</version>
|
||||
<licence>agpl</licence>
|
||||
<author mail="xefir@crystalyx.net" homepage="https://git.crystalyx.net/Xefir/RePod">Xéfir Destiny</author>
|
||||
<author mail="xefir@crystalyx.net" homepage="https://crystalyx.net">Michel Roux</author>
|
||||
<namespace>RePod</namespace>
|
||||
<category>integration</category>
|
||||
<category>multimedia</category>
|
||||
<bugs>https://git.crystalyx.net/Xefir/RePod/issues</bugs>
|
||||
<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.0"/>
|
||||
<nextcloud min-version="26" max-version="28"/>
|
||||
<php min-version="8.1"/>
|
||||
<nextcloud min-version="29" max-version="30"/>
|
||||
</dependencies>
|
||||
<navigations>
|
||||
<navigation>
|
||||
<id>repod</id>
|
||||
<name>Podcast</name>
|
||||
<route>repod.page.index</route>
|
||||
<icon>app.svg</icon>
|
||||
<type>link</type>
|
||||
</navigation>
|
||||
</navigations>
|
||||
</info>
|
||||
|
@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Create your routes in here. The name is the lowercase name of the controller
|
||||
* without the controller part, the stuff after the hash is the method.
|
||||
* e.g. page#index -> OCA\RePod\Controller\PageController->index().
|
||||
*
|
||||
* The controller class has to be registered in the application.php file since
|
||||
* it's instantiated in there
|
||||
*/
|
||||
return [
|
||||
'routes' => [
|
||||
['name' => 'page#index', 'url' => '/', 'verb' => 'GET'],
|
||||
['name' => 'episodes#action', 'url' => '/episodes/action', 'verb' => 'GET'],
|
||||
['name' => 'episodes#list', 'url' => '/episodes/list', 'verb' => 'GET'],
|
||||
['name' => 'podcast#index', 'url' => '/podcast', 'verb' => 'GET'],
|
||||
['name' => 'search#index', 'url' => '/search', 'verb' => 'GET'],
|
||||
['name' => 'toplist#hot', 'url' => '/toplist/hot', 'verb' => 'GET'],
|
||||
['name' => 'toplist#new', 'url' => '/toplist/new', 'verb' => 'GET'],
|
||||
],
|
||||
];
|
@ -1,3 +0,0 @@
|
||||
const babelConfig = require('@nextcloud/babel-config')
|
||||
|
||||
module.exports = babelConfig
|
@ -1,29 +1,30 @@
|
||||
{
|
||||
"name": "nextcloud/repod",
|
||||
"description": "🔊 Browse, manage and listen to podcasts",
|
||||
"type": "project",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"require-dev": {
|
||||
"nextcloud/ocp": "^28.0.1",
|
||||
"psalm/phar": "^5.19.0",
|
||||
"nextcloud/coding-standard": "^1.1.1"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "find . -name \\*.php -not -path './vendor/*' -print0 | xargs -0 -n1 php -l",
|
||||
"cs:check": "php-cs-fixer fix --dry-run --diff",
|
||||
"cs:fix": "php-cs-fixer fix",
|
||||
"psalm:check": "psalm.phar --threads=1 --no-cache --show-info=true",
|
||||
"psalm:fix": "psalm.phar --no-cache --alter --issues=InvalidReturnType,InvalidNullableReturnType,MissingParamType,InvalidFalsableReturnType"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"OCA\\RePod\\": "lib/",
|
||||
"OCA\\GPodderSync\\": "stubs/OCA/GPodderSync/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "find . -name \\*.php -not -path './vendor/*' -not -path './vendor-bin/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l",
|
||||
"cs:check": "php-cs-fixer fix --dry-run --diff",
|
||||
"cs:fix": "php-cs-fixer fix",
|
||||
"psalm": "psalm --threads=1 --no-cache --show-info=true"
|
||||
},
|
||||
"require-dev": {
|
||||
"nextcloud/ocp": "^30.0.2",
|
||||
"roave/security-advisories": "dev-latest",
|
||||
"nextcloud/coding-standard": "^1.3.2",
|
||||
"vimeo/psalm": "^5.26.1"
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"sort-packages": true,
|
||||
"platform": {
|
||||
"php": "8.0"
|
||||
"php": "8.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
2945
composer.lock
generated
2945
composer.lock
generated
File diff suppressed because it is too large
Load Diff
54
l10n/de.js
Normal file
54
l10n/de.js
Normal file
@ -0,0 +1,54 @@
|
||||
OC.L10N.register(
|
||||
"repod",
|
||||
{
|
||||
"RePod Subscriptions" : "RePod Abonnements",
|
||||
"Podcast" : "Podcast",
|
||||
"RePod" : "RePod",
|
||||
"🔊 Browse, manage and listen to podcasts" : "🔊 Suchen, Verwalten und Anhören von Podcasts",
|
||||
"## Features\n- 🔍 Browse and subscribe huge collection of podcasts\n- 🔊 Listen to episodes directly in Nextcloud\n- 🌐 Sync your activity with [AntennaPod](https://antennapod.org/) and [other apps](https://git.crystalyx.net/Xefir/repod#clients-supporting-sync-of-gpoddersync)\n- 📱 Mobile friendly interface\n- 📡 Import and export your subscriptions\n- ➡️ Full features comparison [here](https://git.crystalyx.net/Xefir/repod#comparaison-with-similar-apps-for-nextcloud)\n\n## Requirements\nYou need to have [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installed to use this app!" : "## Funktionen\n- 🔍 Durchsuchen und abonnieren einer großen Sammlung von Podcasts\n- 🔊 Episoden direkt in Nextcloud anhören\n- 🌐 Synchronisiere deine Aktivität mit [AntennaPod](https://antennapod.org/)\n- 📱 Mobile-freundliche Schnittstelle\n- 📡 Importieren und Exportieren Ihrer Abonnements\n\n## Voraussetzungen\nDu musst [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installiert haben, um diese App zu benutzen!",
|
||||
"Download" : "Herunterladen",
|
||||
"Add a RSS link" : "Einen RSS-Link hinzufügen",
|
||||
"Subscribe" : "Abonnieren",
|
||||
"Error while adding the feed" : "Fehler beim Hinzufügen des Feeds",
|
||||
"Could not fetch search results" : "Suchergebnisse können nicht geladen werden",
|
||||
"New podcasts" : "Neue Podcasts",
|
||||
"Hot podcasts" : "Beliebte Podcasts",
|
||||
"Could not fetch tops" : "Titel können nicht abgerufen werden",
|
||||
"Copy feed" : "Feed kopieren",
|
||||
"Link copied to the clipboard" : "Der Link des Feeds wurde in die Zwischenablage kopiert",
|
||||
"Play" : "Abspielen",
|
||||
"Stop" : "Stopp",
|
||||
"Read" : "Gelesen",
|
||||
"Open website" : "Webseite aufrufen",
|
||||
"Could not change the status of the episode" : "Kann den Status der Folge nicht ändern",
|
||||
"Could not fetch episodes" : "Folgen können nicht abgerufen werden",
|
||||
"Rewind 10 seconds" : "10 Sekunden zurückspulen",
|
||||
"Pause" : "Pause",
|
||||
"Fast forward 30 seconds" : "30 Sekunden vorspulen",
|
||||
"Mute" : "Stumm",
|
||||
"Unmute" : "Stummschalten",
|
||||
"Export subscriptions" : "Abonnements exportieren",
|
||||
"Filtering episodes" : "Folgen filtern",
|
||||
"Show all" : "Zeige alles",
|
||||
"Listened" : "Gehört",
|
||||
"Listening" : "Läuft",
|
||||
"Unlistened" : "Nicht angehört",
|
||||
"Import subscriptions" : "Importiere Abonnements",
|
||||
"Import OPML file" : "Importiere OPML-Datei",
|
||||
"Rate RePod ❤️" : "Bewerte RePod ❤️",
|
||||
"Playback speed" : "Wiedergabegeschwindigkeit",
|
||||
"Favorite" : "Favorit",
|
||||
"Are you sure you want to delete this subscription?" : "Bist Du sicher, dass Du das Abonnement löschen möchtest?",
|
||||
"Error while removing the feed" : "Fehler beim Löschen des Feeds",
|
||||
"You can only have 10 favorites" : "Du kannst nur 10 Favoriten haben",
|
||||
"Add a podcast" : "Einen Podcast hinzufügen",
|
||||
"Could not fetch subscriptions" : "Abonnements können nicht abgerufen werden",
|
||||
"Find a podcast" : "Finde einen Podcast",
|
||||
"Error loading feed" : "Fehler beim Laden des Feeds",
|
||||
"Missing required app" : "Benötigte App fehlt",
|
||||
"Install GPodder Sync" : "Installiere GPodder Sync",
|
||||
"Pin some subscriptions to see their latest updates" : "Pinne einige Abonnements, um ihre neuesten Updates zu sehen",
|
||||
"No favorites" : "Keine Favoriten",
|
||||
"A browser extension conflict with RePod" : "Ein Browser-Erweiterungskonflikt mit RePod"
|
||||
},
|
||||
"");
|
52
l10n/de.json
Normal file
52
l10n/de.json
Normal file
@ -0,0 +1,52 @@
|
||||
{ "translations": {
|
||||
"RePod Subscriptions" : "RePod Abonnements",
|
||||
"Podcast" : "Podcast",
|
||||
"RePod" : "RePod",
|
||||
"🔊 Browse, manage and listen to podcasts" : "🔊 Suchen, Verwalten und Anhören von Podcasts",
|
||||
"## Features\n- 🔍 Browse and subscribe huge collection of podcasts\n- 🔊 Listen to episodes directly in Nextcloud\n- 🌐 Sync your activity with [AntennaPod](https://antennapod.org/) and [other apps](https://git.crystalyx.net/Xefir/repod#clients-supporting-sync-of-gpoddersync)\n- 📱 Mobile friendly interface\n- 📡 Import and export your subscriptions\n- ➡️ Full features comparison [here](https://git.crystalyx.net/Xefir/repod#comparaison-with-similar-apps-for-nextcloud)\n\n## Requirements\nYou need to have [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installed to use this app!" : "## Funktionen\n- 🔍 Durchsuchen und abonnieren einer großen Sammlung von Podcasts\n- 🔊 Episoden direkt in Nextcloud anhören\n- 🌐 Synchronisiere deine Aktivität mit [AntennaPod](https://antennapod.org/)\n- 📱 Mobile-freundliche Schnittstelle\n- 📡 Importieren und Exportieren Ihrer Abonnements\n\n## Voraussetzungen\nDu musst [GPodderSync](https://apps.nextcloud.com/apps/gpoddersync) installiert haben, um diese App zu benutzen!",
|
||||
"Download" : "Herunterladen",
|
||||
"Add a RSS link" : "Einen RSS-Link hinzufügen",
|
||||
"Subscribe" : "Abonnieren",
|
||||
"Error while adding the feed" : "Fehler beim Hinzufügen des Feeds",
|
||||
"Could not fetch search results" : "Suchergebnisse können nicht geladen werden",
|
||||
"New podcasts" : "Neue Podcasts",
|
||||
"Hot podcasts" : "Beliebte Podcasts",
|
||||
"Could not fetch tops" : "Titel können nicht abgerufen werden",
|
||||
"Copy feed" : "Feed kopieren",
|
||||
"Link copied to the clipboard" : "Der Link des Feeds wurde in die Zwischenablage kopiert",
|
||||
"Play" : "Abspielen",
|
||||
"Stop" : "Stopp",
|
||||
"Read" : "Gelesen",
|
||||
"Open website" : "Webseite aufrufen",
|
||||
"Could not change the status of the episode" : "Kann den Status der Folge nicht ändern",
|
||||
"Could not fetch episodes" : "Folgen können nicht abgerufen werden",
|
||||
"Rewind 10 seconds" : "10 Sekunden zurückspulen",
|
||||
"Pause" : "Pause",
|
||||
"Fast forward 30 seconds" : "30 Sekunden vorspulen",
|
||||
"Mute" : "Stumm",
|
||||
"Unmute" : "Stummschalten",
|
||||
"Export subscriptions" : "Abonnements exportieren",
|
||||
"Filtering episodes" : "Folgen filtern",
|
||||
"Show all" : "Zeige alles",
|
||||
"Listened" : "Gehört",
|
||||
"Listening" : "Läuft",
|
||||
"Unlistened" : "Nicht angehört",
|
||||
"Import subscriptions" : "Importiere Abonnements",
|
||||
"Import OPML file" : "Importiere OPML-Datei",
|
||||
"Rate RePod ❤️" : "Bewerte RePod ❤️",
|
||||
"Playback speed" : "Wiedergabegeschwindigkeit",
|
||||
"Favorite" : "Favorit",
|
||||
"Are you sure you want to delete this subscription?" : "Bist Du sicher, dass Du das Abonnement löschen möchtest?",
|
||||
"Error while removing the feed" : "Fehler beim Löschen des Feeds",
|
||||
"You can only have 10 favorites" : "Du kannst nur 10 Favoriten haben",
|
||||
"Add a podcast" : "Einen Podcast hinzufügen",
|
||||
"Could not fetch subscriptions" : "Abonnements können nicht abgerufen werden",
|
||||
"Find a podcast" : "Finde einen Podcast",
|
||||
"Error loading feed" : "Fehler beim Laden des Feeds",
|
||||
"Missing required app" : "Benötigte App fehlt",
|
||||
"Install GPodder Sync" : "Installiere GPodder Sync",
|
||||
"Pin some subscriptions to see their latest updates" : "Pinne einige Abonnements, um ihre neuesten Updates zu sehen",
|
||||
"No favorites" : "Keine Favoriten",
|
||||
"A browser extension conflict with RePod" : "Ein Browser-Erweiterungskonflikt mit RePod"
|
||||
},"pluralForm" :""
|
||||
}
|
42
l10n/fr.js
42
l10n/fr.js
@ -1,28 +1,54 @@
|
||||
OC.L10N.register(
|
||||
"repod",
|
||||
{
|
||||
"RePod Subscriptions" : "Abonnements sur RePod",
|
||||
"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/)\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 !",
|
||||
"## 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 !",
|
||||
"Download" : "Télécharger",
|
||||
"Add a RSS link" : "Ajouter un lien RSS",
|
||||
"Could not fetch search results" : "Impossible de récupérer les resultats de la recherche",
|
||||
"Hot podcasts" : "Tendances",
|
||||
"New podcasts" : "Nouveautés",
|
||||
"Could not fetch tops" : "Impossible de récupérer les tops",
|
||||
"Subscribe" : "S'abonner",
|
||||
"Error while adding the feed" : "Erreur lors de l'ajout du flux",
|
||||
"Could not fetch search results" : "Impossible de récupérer les resultats de la recherche",
|
||||
"New podcasts" : "Nouveautés",
|
||||
"Hot podcasts" : "Tendances",
|
||||
"Could not fetch tops" : "Impossible de récupérer les tops",
|
||||
"Copy feed" : "Copier le flux",
|
||||
"Link copied to the clipboard" : "Lien vers le flux copié dans le presse-papiers",
|
||||
"Play" : "Lecture",
|
||||
"Stop" : "Arrêter",
|
||||
"Read" : "Lu",
|
||||
"Open website" : "Ouvrir le site web",
|
||||
"Could not change the status of the episode" : "Impossible de changer le status de l'épisode",
|
||||
"Could not fetch episodes" : "Impossible de récuprer les épisodes",
|
||||
"Download" : "Télécharger",
|
||||
"Delete" : "Supprimer",
|
||||
"Rewind 10 seconds" : "Retour rapide de 10 secondes",
|
||||
"Pause" : "Pause",
|
||||
"Fast forward 30 seconds" : "Avance rapide de 30 secondes",
|
||||
"Mute" : "Silencer",
|
||||
"Unmute" : "Paroler",
|
||||
"Export subscriptions" : "Exporter les abonnements",
|
||||
"Filtering episodes" : "Filtrage des épisodes",
|
||||
"Show all" : "Montrer tout",
|
||||
"Listened" : "Écoutés",
|
||||
"Listening" : "En cours",
|
||||
"Unlistened" : "Non lus",
|
||||
"Import subscriptions" : "Importer les abonnements",
|
||||
"Import OPML file" : "Importer un fichier OPML",
|
||||
"Rate RePod ❤️" : "Donnez votre avis ❤️",
|
||||
"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"
|
||||
"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"
|
||||
},
|
||||
"");
|
||||
|
42
l10n/fr.json
42
l10n/fr.json
@ -1,26 +1,52 @@
|
||||
{ "translations": {
|
||||
"RePod Subscriptions" : "Abonnements sur RePod",
|
||||
"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/)\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 !",
|
||||
"## 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 !",
|
||||
"Download" : "Télécharger",
|
||||
"Add a RSS link" : "Ajouter un lien RSS",
|
||||
"Could not fetch search results" : "Impossible de récupérer les resultats de la recherche",
|
||||
"Hot podcasts" : "Tendances",
|
||||
"New podcasts" : "Nouveautés",
|
||||
"Could not fetch tops" : "Impossible de récupérer les tops",
|
||||
"Subscribe" : "S'abonner",
|
||||
"Error while adding the feed" : "Erreur lors de l'ajout du flux",
|
||||
"Could not fetch search results" : "Impossible de récupérer les resultats de la recherche",
|
||||
"New podcasts" : "Nouveautés",
|
||||
"Hot podcasts" : "Tendances",
|
||||
"Could not fetch tops" : "Impossible de récupérer les tops",
|
||||
"Copy feed" : "Copier le flux",
|
||||
"Link copied to the clipboard" : "Lien vers le flux copié dans le presse-papiers",
|
||||
"Play" : "Lecture",
|
||||
"Stop" : "Arrêter",
|
||||
"Read" : "Lu",
|
||||
"Open website" : "Ouvrir le site web",
|
||||
"Could not change the status of the episode" : "Impossible de changer le status de l'épisode",
|
||||
"Could not fetch episodes" : "Impossible de récuprer les épisodes",
|
||||
"Download" : "Télécharger",
|
||||
"Delete" : "Supprimer",
|
||||
"Rewind 10 seconds" : "Retour rapide de 10 secondes",
|
||||
"Pause" : "Pause",
|
||||
"Fast forward 30 seconds" : "Avance rapide de 30 secondes",
|
||||
"Mute" : "Silencer",
|
||||
"Unmute" : "Paroler",
|
||||
"Export subscriptions" : "Exporter les abonnements",
|
||||
"Filtering episodes" : "Filtrage des épisodes",
|
||||
"Show all" : "Montrer tout",
|
||||
"Listened" : "Écoutés",
|
||||
"Listening" : "En cours",
|
||||
"Unlistened" : "Non lus",
|
||||
"Import subscriptions" : "Importer les abonnements",
|
||||
"Import OPML file" : "Importer un fichier OPML",
|
||||
"Rate RePod ❤️" : "Donnez votre avis ❤️",
|
||||
"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"
|
||||
"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" :""
|
||||
}
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\RePod\AppInfo;
|
||||
|
||||
use OCA\RePod\Service\SearchProvider;
|
||||
use OCP\App\AppPathNotFoundException;
|
||||
use OCP\App\IAppManager;
|
||||
use OCP\AppFramework\App;
|
||||
@ -31,6 +32,10 @@ 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 {
|
||||
@ -43,5 +48,7 @@ class Application extends App implements IBootstrap
|
||||
$initialState->provideInitialState('gpodder', $gpoddersync);
|
||||
}
|
||||
|
||||
public function register(IRegistrationContext $context): void {}
|
||||
public function register(IRegistrationContext $context): void {
|
||||
$context->registerSearchProvider(SearchProvider::class);
|
||||
}
|
||||
}
|
||||
|
@ -4,11 +4,16 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\RePod\Controller;
|
||||
|
||||
use OCA\GPodderSync\Db\EpisodeAction\EpisodeActionRepository;
|
||||
use OCA\RePod\AppInfo\Application;
|
||||
use OCA\RePod\Core\EpisodeAction\EpisodeActionExtraData;
|
||||
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\Http\Client\IClientService;
|
||||
use OCP\IRequest;
|
||||
@ -17,26 +22,32 @@ class EpisodesController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
IRequest $request,
|
||||
private EpisodeActionReader $episodeActionReader,
|
||||
private EpisodeActionRepository $episodeActionRepository,
|
||||
private IClientService $clientService,
|
||||
private EpisodeActionReader $episodeActionReader
|
||||
private UserService $userService
|
||||
) {
|
||||
parent::__construct(Application::APP_ID, $request);
|
||||
}
|
||||
|
||||
#[NoAdminRequired]
|
||||
#[NoCSRFRequired]
|
||||
#[FrontpageRoute(verb: 'GET', url: '/episodes/list')]
|
||||
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) => $b->getFetchedAtUnix() <=> $a->getFetchedAtUnix());
|
||||
$episodes = array_intersect_key($episodes, array_unique(array_map(fn (EpisodeActionExtraData $episode) => $episode->getGuid(), $episodes)));
|
||||
usort($episodes, fn (EpisodeActionExtraData $a, EpisodeActionExtraData $b) => $b->getPubDate() <=> $a->getPubDate());
|
||||
$episodes = array_values(array_intersect_key($episodes, array_unique(array_map(fn (EpisodeActionExtraData $episode) => $episode->getGuid(), $episodes))));
|
||||
|
||||
return new JSONResponse($episodes, $feed->getStatusCode());
|
||||
}
|
||||
|
||||
#[NoAdminRequired]
|
||||
#[NoCSRFRequired]
|
||||
#[FrontpageRoute(verb: 'GET', url: '/episodes/action')]
|
||||
public function action(string $url): JSONResponse {
|
||||
$action = $this->episodeActionReader->findByEpisodeUrl($url);
|
||||
$action = $this->episodeActionRepository->findByEpisodeUrl($url, $this->userService->getUserUID());
|
||||
|
||||
if ($action) {
|
||||
return new JSONResponse($action->toArray());
|
||||
|
109
lib/Controller/OpmlController.php
Normal file
109
lib/Controller/OpmlController.php
Normal file
@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\RePod\Controller;
|
||||
|
||||
use OCA\GPodderSync\Core\PodcastData\PodcastDataReader;
|
||||
use OCA\GPodderSync\Core\PodcastData\PodcastMetricsReader;
|
||||
use OCA\GPodderSync\Core\SubscriptionChange\SubscriptionChangeSaver;
|
||||
use OCA\RePod\AppInfo\Application;
|
||||
use OCA\RePod\Service\UserService;
|
||||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\DataDownloadResponse;
|
||||
use OCP\AppFramework\Http\Response;
|
||||
use OCP\IL10N;
|
||||
use OCP\IRequest;
|
||||
|
||||
class OpmlController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
IRequest $request,
|
||||
private IL10N $l10n,
|
||||
private PodcastDataReader $podcastDataReader,
|
||||
private PodcastMetricsReader $podcastMetricsReader,
|
||||
private SubscriptionChangeSaver $subscriptionChangeSaver,
|
||||
private UserService $userService
|
||||
) {
|
||||
parent::__construct(Application::APP_ID, $request);
|
||||
}
|
||||
|
||||
#[NoAdminRequired]
|
||||
#[NoCSRFRequired]
|
||||
#[FrontpageRoute(verb: 'GET', url: '/opml/export')]
|
||||
public function export(): DataDownloadResponse {
|
||||
// https://github.com/AntennaPod/AntennaPod/blob/master/core/src/main/java/de/danoeh/antennapod/core/export/opml/OpmlWriter.java
|
||||
$xml = new \SimpleXMLElement('<opml/>', namespaceOrPrefix: 'http://xmlpull.org/v1/doc/features.html#indent-output');
|
||||
$xml->addAttribute('version', '2.0');
|
||||
|
||||
$dateCreated = new \DateTime();
|
||||
$head = $xml->addChild('head');
|
||||
|
||||
if (isset($head)) {
|
||||
$head->addChild('title', $this->l10n->t('RePod Subscriptions'));
|
||||
$head->addChild('dateCreated', $dateCreated->format(\DateTime::RFC822));
|
||||
}
|
||||
|
||||
$body = $xml->addChild('body');
|
||||
|
||||
if (isset($body)) {
|
||||
$subscriptions = $this->podcastMetricsReader->metrics($this->userService->getUserUID());
|
||||
|
||||
foreach ($subscriptions as $subscription) {
|
||||
try {
|
||||
$podcast = $this->podcastDataReader->getCachedOrFetchPodcastData($subscription->getUrl(), $this->userService->getUserUID());
|
||||
} catch (\Exception $e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($podcast) {
|
||||
$outline = $body->addChild('outline');
|
||||
|
||||
if (isset($outline)) {
|
||||
$outline->addAttribute('xmlUrl', $subscription->getUrl());
|
||||
|
||||
$title = $podcast->getTitle();
|
||||
$link = $podcast->getLink();
|
||||
|
||||
if (isset($title)) {
|
||||
$outline->addAttribute('text', $title);
|
||||
$outline->addAttribute('title', $title);
|
||||
}
|
||||
|
||||
if (isset($link)) {
|
||||
$outline->addAttribute('htmlUrl', $link);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new DataDownloadResponse((string) $xml->asXML(), 'repod-'.$dateCreated->getTimestamp().'.opml', ' application/xml');
|
||||
}
|
||||
|
||||
#[NoAdminRequired]
|
||||
#[NoCSRFRequired]
|
||||
#[FrontpageRoute(verb: 'POST', url: '/opml/import')]
|
||||
public function import(): Response {
|
||||
$file = $this->request->getUploadedFile('import');
|
||||
|
||||
if ($file) {
|
||||
$xml = new \SimpleXMLElement(file_get_contents((string) $file['tmp_name']));
|
||||
|
||||
/** @var \SimpleXMLElement[] $outlines */
|
||||
$outlines = $xml->body->children();
|
||||
|
||||
$toSubscribe = [];
|
||||
foreach ($outlines as $outline) {
|
||||
$toSubscribe[] = (string) $outline['xmlUrl'];
|
||||
}
|
||||
|
||||
$this->subscriptionChangeSaver->saveSubscriptionChanges($toSubscribe, [], $this->userService->getUserUID());
|
||||
}
|
||||
|
||||
return new Response();
|
||||
}
|
||||
}
|
@ -6,6 +6,9 @@ namespace OCA\RePod\Controller;
|
||||
|
||||
use OCA\RePod\AppInfo\Application;
|
||||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\ContentSecurityPolicy;
|
||||
use OCP\AppFramework\Http\TemplateResponse;
|
||||
use OCP\IConfig;
|
||||
@ -14,30 +17,41 @@ use OCP\Util;
|
||||
|
||||
class PageController extends Controller
|
||||
{
|
||||
public function __construct(IRequest $request, private IConfig $config) {
|
||||
public function __construct(
|
||||
IRequest $request,
|
||||
private IConfig $config
|
||||
) {
|
||||
parent::__construct(Application::APP_ID, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
* @NoCSRFRequired
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[NoCSRFRequired]
|
||||
#[FrontpageRoute(verb: 'GET', url: '/')]
|
||||
public function index(): TemplateResponse {
|
||||
Util::addScript(Application::APP_ID, 'repod-main');
|
||||
Util::addScript(Application::APP_ID, Application::APP_ID.'-main');
|
||||
Util::addStyle(Application::APP_ID, Application::APP_ID.'-main');
|
||||
|
||||
$csp = new ContentSecurityPolicy();
|
||||
$csp->addAllowedImageDomain('*');
|
||||
$csp->addAllowedMediaDomain('*');
|
||||
|
||||
if ($this->config->getSystemValueBool('debug')) {
|
||||
// Unblock HMR requests.
|
||||
$csp->addAllowedConnectDomain('*');
|
||||
$csp->addAllowedScriptDomain('*');
|
||||
}
|
||||
|
||||
$response = new TemplateResponse(Application::APP_ID, 'main');
|
||||
$response->setContentSecurityPolicy($csp);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
#[NoAdminRequired]
|
||||
#[NoCSRFRequired]
|
||||
#[FrontpageRoute(verb: 'GET', url: '/discover')]
|
||||
public function discover(): TemplateResponse {
|
||||
return $this->index();
|
||||
}
|
||||
|
||||
#[NoAdminRequired]
|
||||
#[NoCSRFRequired]
|
||||
#[FrontpageRoute(verb: 'GET', url: '/feed/{path}', requirements: ['path' => '.+'])]
|
||||
public function feed(): TemplateResponse {
|
||||
return $this->index();
|
||||
}
|
||||
}
|
||||
|
@ -7,25 +7,38 @@ namespace OCA\RePod\Controller;
|
||||
use OCA\GPodderSync\Core\PodcastData\PodcastData;
|
||||
use OCA\GPodderSync\Core\PodcastData\PodcastDataReader;
|
||||
use OCA\RePod\AppInfo\Application;
|
||||
use OCA\RePod\Service\UserService;
|
||||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
use OCP\Http\Client\IClientService;
|
||||
use OCP\ICacheFactory;
|
||||
use OCP\IRequest;
|
||||
|
||||
class PodcastController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
IRequest $request,
|
||||
private ICacheFactory $cacheFactory,
|
||||
private IClientService $clientService,
|
||||
private UserService $userService,
|
||||
private PodcastDataReader $podcastDataReader
|
||||
) {
|
||||
parent::__construct(Application::APP_ID, $request);
|
||||
}
|
||||
|
||||
#[NoAdminRequired]
|
||||
#[NoCSRFRequired]
|
||||
#[FrontpageRoute(verb: 'GET', url: '/podcast')]
|
||||
public function index(string $url): JSONResponse {
|
||||
$podcast = $this->podcastDataReader->tryGetCachedPodcastData($url);
|
||||
$podcast = null;
|
||||
|
||||
if ($this->cacheFactory->isLocalCacheAvailable()) {
|
||||
try {
|
||||
$podcast = $this->podcastDataReader->tryGetCachedPodcastData($url);
|
||||
} catch (\Exception $e) {
|
||||
}
|
||||
}
|
||||
|
||||
if ($podcast) {
|
||||
return new JSONResponse($podcast);
|
||||
@ -33,9 +46,14 @@ class PodcastController extends Controller
|
||||
|
||||
$client = $this->clientService->newClient();
|
||||
$feed = $client->get($url);
|
||||
|
||||
$podcast = PodcastData::parseRssXml((string) $feed->getBody());
|
||||
$this->podcastDataReader->trySetCachedPodcastData($url, $podcast);
|
||||
|
||||
if ($this->cacheFactory->isLocalCacheAvailable()) {
|
||||
try {
|
||||
$this->podcastDataReader->trySetCachedPodcastData($url, $podcast);
|
||||
} catch (\Exception $e) {
|
||||
}
|
||||
}
|
||||
|
||||
return new JSONResponse($podcast, $feed->getStatusCode());
|
||||
}
|
||||
|
@ -4,41 +4,28 @@ 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 OCA\RePod\Service\ItunesService;
|
||||
use OCA\RePod\Service\MultiPodService;
|
||||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
use OCP\IRequest;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class SearchController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
IRequest $request,
|
||||
private LoggerInterface $logger,
|
||||
private FyydService $fyydService,
|
||||
private ItunesService $itunesService
|
||||
private MultiPodService $multiPodService
|
||||
) {
|
||||
parent::__construct(Application::APP_ID, $request);
|
||||
}
|
||||
|
||||
public function index(string $value): JSONResponse {
|
||||
$podcasts = [];
|
||||
$providers = [$this->fyydService, $this->itunesService];
|
||||
|
||||
foreach ($providers as $provider) {
|
||||
try {
|
||||
$podcasts = [...$podcasts, ...$provider->search($value)];
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error($e->getMessage(), $e->getTrace());
|
||||
}
|
||||
}
|
||||
|
||||
usort($podcasts, fn (PodcastData $a, PodcastData $b) => $b->getFetchedAtUnix() <=> $a->getFetchedAtUnix());
|
||||
$podcasts = array_intersect_key($podcasts, array_unique(array_map(fn (PodcastData $feed) => $feed->getLink(), $podcasts)));
|
||||
|
||||
return new JSONResponse($podcasts);
|
||||
#[NoAdminRequired]
|
||||
#[NoCSRFRequired]
|
||||
#[FrontpageRoute(verb: 'GET', url: '/search')]
|
||||
public function index(string $q): JSONResponse {
|
||||
return new JSONResponse($this->multiPodService->search($q));
|
||||
}
|
||||
}
|
||||
|
@ -7,6 +7,9 @@ namespace OCA\RePod\Controller;
|
||||
use OCA\RePod\AppInfo\Application;
|
||||
use OCA\RePod\Service\FyydService;
|
||||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
use OCP\IRequest;
|
||||
|
||||
@ -19,10 +22,16 @@ class ToplistController extends Controller
|
||||
parent::__construct(Application::APP_ID, $request);
|
||||
}
|
||||
|
||||
#[NoAdminRequired]
|
||||
#[NoCSRFRequired]
|
||||
#[FrontpageRoute(verb: 'GET', url: '/toplist/hot')]
|
||||
public function hot(): JSONResponse {
|
||||
return new JSONResponse($this->fyydService->hot());
|
||||
}
|
||||
|
||||
#[NoAdminRequired]
|
||||
#[NoCSRFRequired]
|
||||
#[FrontpageRoute(verb: 'GET', url: '/toplist/new')]
|
||||
public function new(): JSONResponse {
|
||||
return new JSONResponse($this->fyydService->latest());
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ use OCA\GPodderSync\Core\EpisodeAction\EpisodeAction;
|
||||
* @psalm-import-type EpisodeActionType from EpisodeAction
|
||||
*
|
||||
* @psalm-type EpisodeActionExtraDataType = array{
|
||||
* podcast: string,
|
||||
* title: string,
|
||||
* url: ?string,
|
||||
* name: string,
|
||||
* link: ?string,
|
||||
@ -24,14 +24,14 @@ use OCA\GPodderSync\Core\EpisodeAction\EpisodeAction;
|
||||
* type: ?string,
|
||||
* size: ?int,
|
||||
* pubDate: ?\DateTime,
|
||||
* duration: ?int,
|
||||
* duration: ?string,
|
||||
* action: ?EpisodeActionType
|
||||
* }
|
||||
*/
|
||||
class EpisodeActionExtraData implements \JsonSerializable
|
||||
{
|
||||
public function __construct(
|
||||
private string $podcast,
|
||||
private string $title,
|
||||
private ?string $url,
|
||||
private string $name,
|
||||
private ?string $link,
|
||||
@ -42,7 +42,7 @@ class EpisodeActionExtraData implements \JsonSerializable
|
||||
private ?string $type,
|
||||
private ?int $size,
|
||||
private ?\DateTime $pubDate,
|
||||
private ?int $duration,
|
||||
private ?string $duration,
|
||||
private ?EpisodeAction $action
|
||||
) {}
|
||||
|
||||
@ -50,8 +50,8 @@ class EpisodeActionExtraData implements \JsonSerializable
|
||||
return $this->url ?? '/no episodeUrl/';
|
||||
}
|
||||
|
||||
public function getPodcast(): string {
|
||||
return $this->podcast;
|
||||
public function getTitle(): string {
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function getUrl(): ?string {
|
||||
@ -94,7 +94,7 @@ class EpisodeActionExtraData implements \JsonSerializable
|
||||
return $this->pubDate;
|
||||
}
|
||||
|
||||
public function getDuration(): ?int {
|
||||
public function getDuration(): ?string {
|
||||
return $this->duration;
|
||||
}
|
||||
|
||||
@ -108,7 +108,7 @@ class EpisodeActionExtraData implements \JsonSerializable
|
||||
public function toArray(): array {
|
||||
return
|
||||
[
|
||||
'podcast' => $this->podcast,
|
||||
'title' => $this->title,
|
||||
'url' => $this->url,
|
||||
'name' => $this->name,
|
||||
'link' => $this->link,
|
||||
|
@ -4,21 +4,17 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\RePod\Core\EpisodeAction;
|
||||
|
||||
use OCA\GPodderSync\Core\EpisodeAction\EpisodeAction;
|
||||
use OCA\GPodderSync\Core\EpisodeAction\EpisodeActionReader as CoreEpisodeActionReader;
|
||||
use OCA\GPodderSync\Db\EpisodeAction\EpisodeActionRepository;
|
||||
use OCA\RePod\Service\UserService;
|
||||
|
||||
class EpisodeActionReader
|
||||
class EpisodeActionReader extends CoreEpisodeActionReader
|
||||
{
|
||||
public function __construct(
|
||||
private EpisodeActionRepository $episodeActionRepository,
|
||||
private UserService $userService
|
||||
) {}
|
||||
|
||||
public function findByEpisodeUrl(string $episodeUrl): ?EpisodeAction {
|
||||
return $this->episodeActionRepository->findByEpisodeUrl($episodeUrl, $this->userService->getUserUID());
|
||||
}
|
||||
|
||||
/**
|
||||
* Base: https://github.com/pbek/nextcloud-nextpod/blob/main/lib/Core/EpisodeAction/EpisodeActionExtraData.php#L119.
|
||||
* Specs : https://github.com/Podcast-Standards-Project/PSP-1-Podcast-RSS-Specification/blob/main/README.md.
|
||||
@ -30,7 +26,7 @@ class EpisodeActionReader
|
||||
$episodes = [];
|
||||
$xml = new \SimpleXMLElement($xmlString);
|
||||
$channel = $xml->channel;
|
||||
$podcast = (string) $channel->title;
|
||||
$title = (string) $channel->title;
|
||||
|
||||
// Find episode by url and add data for it
|
||||
/** @var \SimpleXMLElement $item */
|
||||
@ -39,7 +35,9 @@ class EpisodeActionReader
|
||||
$type = (string) $item->enclosure['type'];
|
||||
$size = (int) $item->enclosure['length'];
|
||||
$guid = (string) $item->guid;
|
||||
$rawDuration = $this->stringOrNull($item->duration);
|
||||
|
||||
$iTunesItemChildren = $item->children('itunes', true);
|
||||
$iTunesChannelChildren = $channel->children('itunes', true);
|
||||
|
||||
// Get episode action
|
||||
$action = $this->episodeActionRepository->findByGuid($guid, $this->userService->getUserUID());
|
||||
@ -57,65 +55,65 @@ class EpisodeActionReader
|
||||
$link = $this->stringOrNull($item->link);
|
||||
|
||||
// Get episode image
|
||||
$image = $this->stringOrNull($channel->image->url);
|
||||
if (isset($iTunesItemChildren)) {
|
||||
$imageAttributes = $iTunesItemChildren->image->attributes();
|
||||
$image = $this->stringOrNull(isset($imageAttributes) ? (string) $imageAttributes->href : '');
|
||||
}
|
||||
|
||||
$itemChildren = $item->children('http://www.itunes.com/dtds/podcast-1.0.dtd');
|
||||
if ($itemChildren) {
|
||||
$imageAttributes = (array) $itemChildren->image->attributes();
|
||||
$image = $this->stringOrNull(array_key_exists('href', $imageAttributes) ? (string) $imageAttributes['href'] : '');
|
||||
$iTunesItemChildren = $item->children('itunes', true);
|
||||
$iTunesChannelChildren = $channel->children('itunes', true);
|
||||
if (!isset($image) && isset($iTunesChannelChildren)) {
|
||||
$imageAttributes = $iTunesChannelChildren->image->attributes();
|
||||
$image = $this->stringOrNull(isset($imageAttributes) ? (string) $imageAttributes->href : '');
|
||||
}
|
||||
|
||||
// Get episode duration
|
||||
if ($iTunesItemChildren) {
|
||||
$rawDuration = $this->stringOrNull($rawDuration ?? $iTunesItemChildren->duration);
|
||||
}
|
||||
if (!isset($image)) {
|
||||
$image = $this->stringOrNull($item->image->url);
|
||||
}
|
||||
|
||||
if ($iTunesItemChildren && !$image) {
|
||||
$image = $this->stringOrNull($iTunesItemChildren->image['href']);
|
||||
}
|
||||
if (!isset($image)) {
|
||||
$image = $this->stringOrNull($channel->image->url);
|
||||
}
|
||||
|
||||
if ($iTunesChannelChildren && !$image) {
|
||||
$image = $this->stringOrNull($iTunesChannelChildren->image['href']);
|
||||
}
|
||||
|
||||
if (!$image) {
|
||||
$channelChildren = $channel->children('http://www.itunes.com/dtds/podcast-1.0.dtd');
|
||||
if ($channelChildren) {
|
||||
$imageAttributes = (array) $channelChildren->image->attributes();
|
||||
$image = $this->stringOrNull(array_key_exists('href', $imageAttributes) ? (string) $imageAttributes['href'] : '');
|
||||
}
|
||||
}
|
||||
|
||||
if (!$image) {
|
||||
preg_match('/<itunes:image\s+href="([^"]+)"/', $xmlString, $matches);
|
||||
if (!isset($image)) {
|
||||
preg_match('/<itunes:image\s+href="([^"]+)"/', $xmlString, $matches);
|
||||
if (count($matches) > 1) {
|
||||
$image = $this->stringOrNull($matches[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Get episode description
|
||||
$itemContent = $item->children('content', true);
|
||||
if ($itemContent) {
|
||||
if (isset($itemContent)) {
|
||||
$description = $this->stringOrNull($itemContent->encoded);
|
||||
} else {
|
||||
$description = $this->stringOrNull($item->description);
|
||||
}
|
||||
|
||||
// Remove tags
|
||||
$description = strip_tags($description ?? '');
|
||||
if (!isset($description) && isset($iTunesItemChildren)) {
|
||||
$description = $this->stringOrNull($iTunesItemChildren->summary);
|
||||
}
|
||||
|
||||
// Get episode pubDate
|
||||
$rawPubDate = $this->stringOrNull($item->pubDate);
|
||||
$pubDate = $rawPubDate ? new \DateTime($rawPubDate) : null;
|
||||
// Remove tags
|
||||
$description = strip_tags(str_replace(['<br>', '<br/>', '<br />'], "\n", $description ?? ''));
|
||||
|
||||
// Get episode duration
|
||||
$splitDuration = array_reverse(explode(':', $rawDuration ?? ''));
|
||||
$duration = (int) $splitDuration[0];
|
||||
$duration += !empty($splitDuration[1]) ? (int) $splitDuration[1] * 60 : 0;
|
||||
$duration += !empty($splitDuration[2]) ? (int) $splitDuration[2] * 60 : 0;
|
||||
if (isset($iTunesItemChildren)) {
|
||||
$duration = $this->stringOrNull($iTunesItemChildren->duration);
|
||||
} else {
|
||||
$duration = $this->stringOrNull($item->duration);
|
||||
}
|
||||
|
||||
// Get episode pubDate
|
||||
$pubDate = $this->stringOrNull($item->pubDate);
|
||||
if (isset($pubDate)) {
|
||||
try {
|
||||
$pubDate = new \DateTime($pubDate);
|
||||
} catch (\Exception $e) {
|
||||
$pubDate = null;
|
||||
}
|
||||
}
|
||||
|
||||
$episodes[] = new EpisodeActionExtraData(
|
||||
$podcast,
|
||||
$title,
|
||||
$url,
|
||||
$name,
|
||||
$link,
|
||||
@ -138,7 +136,8 @@ class EpisodeActionReader
|
||||
* @param null|\SimpleXMLElement|string $value
|
||||
*/
|
||||
private function stringOrNull($value): ?string {
|
||||
if ($value) {
|
||||
/** @psalm-suppress RiskyTruthyFalsyComparison */
|
||||
if (!empty($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
|
@ -6,20 +6,16 @@ namespace OCA\RePod\Service;
|
||||
|
||||
use OCA\GPodderSync\Core\PodcastData\PodcastData;
|
||||
use OCP\Http\Client\IClientService;
|
||||
use OCP\IUserSession;
|
||||
use OCP\L10N\IFactory;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class FyydService implements IProvider
|
||||
class FyydService implements IPodProvider
|
||||
{
|
||||
private const BASE_URL = 'https://api.fyyd.de/0.2/';
|
||||
|
||||
public function __construct(
|
||||
private UserService $userService,
|
||||
private IClientService $clientService,
|
||||
private IFactory $l10n,
|
||||
private IUserSession $userSession,
|
||||
private LoggerInterface $logger
|
||||
private LoggerInterface $logger,
|
||||
private UserService $userService
|
||||
) {}
|
||||
|
||||
public function search(string $value): array {
|
||||
@ -38,14 +34,16 @@ class FyydService implements IProvider
|
||||
if (array_key_exists('data', $json) && is_array($json['data'])) {
|
||||
/** @var string[] $feed */
|
||||
foreach ($json['data'] as $feed) {
|
||||
$podcasts[] = new PodcastData(
|
||||
$feed['title'],
|
||||
$feed['author'],
|
||||
$feed['xmlURL'],
|
||||
$feed['description'],
|
||||
$feed['imgURL'],
|
||||
strtotime($feed['lastpub'])
|
||||
);
|
||||
if ($feed['title']) {
|
||||
$podcasts[] = new PodcastData(
|
||||
$feed['title'],
|
||||
$feed['author'],
|
||||
$feed['xmlURL'],
|
||||
$feed['description'],
|
||||
$feed['imgURL'],
|
||||
strtotime($feed['lastpub'])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -64,14 +62,16 @@ class FyydService implements IProvider
|
||||
if (array_key_exists('data', $podcastJson) && is_array($podcastJson['data'])) {
|
||||
/** @var string[] $feed */
|
||||
foreach ($podcastJson['data'] as $feed) {
|
||||
$podcasts[] = new PodcastData(
|
||||
$feed['title'],
|
||||
$feed['author'],
|
||||
$feed['xmlURL'],
|
||||
$feed['description'],
|
||||
$feed['imgURL'],
|
||||
strtotime($feed['lastpub'])
|
||||
);
|
||||
if ($feed['title']) {
|
||||
$podcasts[] = new PodcastData(
|
||||
$feed['title'],
|
||||
$feed['author'],
|
||||
$feed['xmlURL'],
|
||||
$feed['description'],
|
||||
$feed['imgURL'],
|
||||
strtotime($feed['lastpub'])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -90,6 +90,7 @@ class FyydService implements IProvider
|
||||
$langClient = $this->clientService->newClient();
|
||||
$langResponse = $langClient->get(self::BASE_URL.'feature/podcast/hot/languages');
|
||||
$langJson = (array) json_decode((string) $langResponse->getBody(), true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
if (array_key_exists('data', $langJson) && is_array($langJson['data'])) {
|
||||
$language = in_array($userLang, $langJson['data']) ? $userLang : 'en';
|
||||
}
|
||||
@ -109,14 +110,16 @@ class FyydService implements IProvider
|
||||
if (array_key_exists('data', $postCastJson) && is_array($postCastJson['data'])) {
|
||||
/** @var string[] $feed */
|
||||
foreach ($postCastJson['data'] as $feed) {
|
||||
$podcasts[] = new PodcastData(
|
||||
$feed['title'],
|
||||
$feed['author'],
|
||||
$feed['xmlURL'],
|
||||
$feed['description'],
|
||||
$feed['imgURL'],
|
||||
strtotime($feed['lastpub'])
|
||||
);
|
||||
if ($feed['title']) {
|
||||
$podcasts[] = new PodcastData(
|
||||
$feed['title'],
|
||||
$feed['author'],
|
||||
$feed['xmlURL'],
|
||||
$feed['description'],
|
||||
$feed['imgURL'],
|
||||
strtotime($feed['lastpub'])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -6,7 +6,7 @@ namespace OCA\RePod\Service;
|
||||
|
||||
use OCA\GPodderSync\Core\PodcastData\PodcastData;
|
||||
|
||||
interface IProvider
|
||||
interface IPodProvider
|
||||
{
|
||||
/**
|
||||
* @return PodcastData[]
|
@ -7,7 +7,7 @@ namespace OCA\RePod\Service;
|
||||
use OCA\GPodderSync\Core\PodcastData\PodcastData;
|
||||
use OCP\Http\Client\IClientService;
|
||||
|
||||
class ItunesService implements IProvider
|
||||
class ItunesService implements IPodProvider
|
||||
{
|
||||
private const BASE_URL = 'https://itunes.apple.com/';
|
||||
|
||||
|
53
lib/Service/MultiPodService.php
Normal file
53
lib/Service/MultiPodService.php
Normal file
@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\RePod\Service;
|
||||
|
||||
use OCA\GPodderSync\Core\PodcastData\PodcastData;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class MultiPodService implements IPodProvider
|
||||
{
|
||||
/**
|
||||
* @var IPodProvider[]
|
||||
*/
|
||||
private array $providers = [];
|
||||
|
||||
public function __construct(
|
||||
FyydService $fyydService,
|
||||
ItunesService $itunesService,
|
||||
private LoggerInterface $logger
|
||||
) {
|
||||
$this->providers = [$fyydService, $itunesService];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return PodcastData[]
|
||||
*/
|
||||
public function search(string $value): array {
|
||||
$podcasts = [];
|
||||
|
||||
foreach ($this->providers as $provider) {
|
||||
try {
|
||||
$podcasts = [...$podcasts, ...$provider->search($value)];
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error($e->getMessage(), $e->getTrace());
|
||||
}
|
||||
}
|
||||
|
||||
usort($podcasts, fn (PodcastData $a, PodcastData $b) => $b->getFetchedAtUnix() <=> $a->getFetchedAtUnix());
|
||||
|
||||
return array_values(
|
||||
array_intersect_key(
|
||||
$podcasts,
|
||||
array_unique(
|
||||
array_map(
|
||||
fn (PodcastData $feed) => $feed->getLink(),
|
||||
array_filter($podcasts, fn (PodcastData $feed) => $feed->getLink())
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
65
lib/Service/SearchProvider.php
Normal file
65
lib/Service/SearchProvider.php
Normal file
@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\RePod\Service;
|
||||
|
||||
use OCA\RePod\AppInfo\Application;
|
||||
use OCP\IL10N;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\IUser;
|
||||
use OCP\Search\IProvider;
|
||||
use OCP\Search\ISearchQuery;
|
||||
use OCP\Search\SearchResult;
|
||||
use OCP\Search\SearchResultEntry;
|
||||
|
||||
class SearchProvider implements IProvider
|
||||
{
|
||||
public function __construct(
|
||||
private IL10N $l10n,
|
||||
private IURLGenerator $urlGenerator,
|
||||
private MultiPodService $multiPodService
|
||||
) {}
|
||||
|
||||
public function getId(): string {
|
||||
return Application::APP_ID;
|
||||
}
|
||||
|
||||
public function getName(): string {
|
||||
return $this->l10n->t('Podcast');
|
||||
}
|
||||
|
||||
public function getOrder(string $route, array $routeParameters): int {
|
||||
if (0 === strpos($route, Application::APP_ID.'.')) {
|
||||
// Active app, prefer my results
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 25;
|
||||
}
|
||||
|
||||
public function search(IUser $user, ISearchQuery $query): SearchResult {
|
||||
$podcasts = $this->multiPodService->search($query->getTerm());
|
||||
|
||||
$searchResults = [];
|
||||
foreach ($podcasts as $podcast) {
|
||||
$title = $podcast->getTitle();
|
||||
$link = $podcast->getLink();
|
||||
|
||||
if (isset($title, $link)) {
|
||||
$searchResults[] = new SearchResultEntry(
|
||||
$podcast->getImageUrl() ?? $this->urlGenerator->linkTo(Application::APP_ID, 'img/app.svg'),
|
||||
$title,
|
||||
$podcast->getAuthor() ?? '',
|
||||
$this->urlGenerator->linkToRoute('repod.page.index').'/#/'.urlencode(base64_encode($link)),
|
||||
$this->urlGenerator->linkTo(Application::APP_ID, 'img/app.svg')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return SearchResult::complete(
|
||||
$this->l10n->t('Podcast'),
|
||||
$searchResults
|
||||
);
|
||||
}
|
||||
}
|
18078
package-lock.json
generated
18078
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
78
package.json
78
package.json
@ -1,47 +1,51 @@
|
||||
{
|
||||
"name": "repod",
|
||||
"description": "🔊 Browse, manage and listen to podcasts",
|
||||
"version": "1.0.0",
|
||||
"bugs": {
|
||||
"url": "https://git.crystalyx.net/Xefir/RePod/issues"
|
||||
},
|
||||
"license": "agpl",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"scripts": {
|
||||
"build": "webpack --node-env production --progress",
|
||||
"dev": "webpack --node-env development --progress",
|
||||
"watch": "webpack --node-env development --progress --watch",
|
||||
"serve": "webpack --node-env development serve --progress",
|
||||
"lint": "eslint --ext .js,.vue src",
|
||||
"lint:fix": "eslint --ext .js,.vue src --fix",
|
||||
"stylelint": "stylelint css/*.css css/*.scss src/**/*.scss src/**/*.vue",
|
||||
"stylelint:fix": "stylelint css/*.css css/*.scss src/**/*.scss src/**/*.vue --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nextcloud/axios": "^2.4.0",
|
||||
"@nextcloud/dialogs": "^5.0.3",
|
||||
"@nextcloud/initial-state": "^2.1.0",
|
||||
"@nextcloud/l10n": "^2.2.0",
|
||||
"@nextcloud/router": "^2.2.0",
|
||||
"@nextcloud/vue": "^8.4.0",
|
||||
"date-fns": "^3.2.0",
|
||||
"vue": "^2",
|
||||
"vue-material-design-icons": "^5.2.0",
|
||||
"vue-router": "^3",
|
||||
"vuex": "^3"
|
||||
"build": "vue-tsc && vite build",
|
||||
"dev": "vite --mode development build",
|
||||
"watch": "vite --mode development build --watch",
|
||||
"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"
|
||||
},
|
||||
"type": "module",
|
||||
"browserslist": [
|
||||
"extends @nextcloud/browserslist-config"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.0.0",
|
||||
"npm": "^9.0.0"
|
||||
"dependencies": {
|
||||
"@formatjs/intl-segmenter": "^11.7.3",
|
||||
"@nextcloud/axios": "^2.5.1",
|
||||
"@nextcloud/initial-state": "^2.2.0",
|
||||
"@nextcloud/l10n": "^3.1.0",
|
||||
"@nextcloud/router": "^3.0.1",
|
||||
"@nextcloud/vite-config": "^2.2.2",
|
||||
"@nextcloud/vue": "9.0.0-alpha.5",
|
||||
"dompurify": "^3.1.7",
|
||||
"linkify-html": "^4.1.3",
|
||||
"pinia": "^2.2.6",
|
||||
"toastify-js": "^1.12.0",
|
||||
"vite": "^5.4.10",
|
||||
"vue": "^3.5.12",
|
||||
"vue-material-design-icons": "^5.3.1",
|
||||
"vue-router": "^4.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nextcloud/babel-config": "^1.0.0",
|
||||
"@nextcloud/browserslist-config": "^3.0.0",
|
||||
"@nextcloud/eslint-config": "^8.3.0",
|
||||
"@nextcloud/stylelint-config": "^2.3.1",
|
||||
"@nextcloud/webpack-vue-config": "^6.0.0"
|
||||
}
|
||||
"@nextcloud/browserslist-config": "^3.0.1",
|
||||
"@nextcloud/eslint-config": "^8.4.1",
|
||||
"@nextcloud/prettier-config": "^1.1.0",
|
||||
"@nextcloud/stylelint-config": "^3.0.1",
|
||||
"@types/toastify-js": "^1.12.3",
|
||||
"@vue/eslint-config-typescript": "^13",
|
||||
"@vue/tsconfig": "^0.5.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-pinia": "^0.4.1",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "5.5",
|
||||
"vue-eslint-parser": "^9.4.3",
|
||||
"vue-tsc": "^2.1.10"
|
||||
},
|
||||
"prettier": "@nextcloud/prettier-config"
|
||||
}
|
||||
|
17
psalm.xml
17
psalm.xml
@ -2,11 +2,12 @@
|
||||
<psalm
|
||||
errorLevel="1"
|
||||
resolveFromConfigFile="true"
|
||||
findUnusedBaselineEntry="true"
|
||||
findUnusedCode="false"
|
||||
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" />
|
||||
@ -17,20 +18,8 @@
|
||||
</projectFiles>
|
||||
<extraFiles>
|
||||
<directory name="vendor" />
|
||||
<ignoreFiles>
|
||||
<directory name="vendor/psalm" />
|
||||
</ignoreFiles>
|
||||
</extraFiles>
|
||||
<issueHandlers>
|
||||
<UndefinedDocblockClass>
|
||||
<errorLevel type="suppress">
|
||||
<referencedClass name="OC\AppFramework\OCS\BaseResponse"/>
|
||||
<referencedClass name="Doctrine\DBAL\Schema\Schema" />
|
||||
<referencedClass name="Doctrine\DBAL\Schema\SchemaException" />
|
||||
<referencedClass name="Doctrine\DBAL\Driver\Statement" />
|
||||
<referencedClass name="Doctrine\DBAL\Schema\Table" />
|
||||
</errorLevel>
|
||||
</UndefinedDocblockClass>
|
||||
<InvalidReturnType>
|
||||
<errorLevel type="suppress">
|
||||
<directory name="stubs" />
|
||||
|
BIN
screens/discover.png
Normal file
BIN
screens/discover.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.7 MiB |
BIN
screens/episodes.png
Normal file
BIN
screens/episodes.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 857 KiB |
BIN
screens/index.png
Normal file
BIN
screens/index.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.7 MiB |
BIN
screens/modal.png
Normal file
BIN
screens/modal.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 817 KiB |
BIN
screens/search.png
Normal file
BIN
screens/search.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 961 KiB |
13
src/App.vue
13
src/App.vue
@ -7,13 +7,15 @@
|
||||
</NcContent>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import '@nextcloud/dialogs/style.css'
|
||||
<script lang="ts">
|
||||
import 'toastify-js/src/toastify.css'
|
||||
import { mapActions, mapState } from 'pinia'
|
||||
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',
|
||||
@ -24,9 +26,16 @@ export default {
|
||||
Subscriptions,
|
||||
},
|
||||
computed: {
|
||||
...mapState(usePlayer, ['paused']),
|
||||
gpodder() {
|
||||
return loadState('repod', 'gpodder', false)
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
},
|
||||
methods: {
|
||||
...mapActions(usePlayer, ['init']),
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
@ -1,22 +0,0 @@
|
||||
<template>
|
||||
<ul :class="episode ? 'margin' : ''">
|
||||
<slot />
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'AdaptativeList',
|
||||
computed: {
|
||||
episode() {
|
||||
return this.$store.state.player.episode
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.margin {
|
||||
margin-bottom: 6rem;
|
||||
}
|
||||
</style>
|
27
src/components/Atoms/AppContent.vue
Normal file
27
src/components/Atoms/AppContent.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<NcAppContent :class="{ episode }">
|
||||
<slot />
|
||||
</NcAppContent>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { NcAppContent } from '@nextcloud/vue'
|
||||
import { mapState } from 'pinia'
|
||||
import { usePlayer } from '../../store/player.ts'
|
||||
|
||||
export default {
|
||||
name: 'AppContent',
|
||||
components: {
|
||||
NcAppContent,
|
||||
},
|
||||
computed: {
|
||||
...mapState(usePlayer, ['episode']),
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.episode {
|
||||
padding-bottom: 6rem;
|
||||
}
|
||||
</style>
|
33
src/components/Atoms/AppNavigation.vue
Normal file
33
src/components/Atoms/AppNavigation.vue
Normal file
@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<NcAppNavigation :class="{ episode }">
|
||||
<slot />
|
||||
<template #list>
|
||||
<slot name="list" />
|
||||
</template>
|
||||
<template #footer>
|
||||
<slot name="footer" />
|
||||
</template>
|
||||
</NcAppNavigation>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { NcAppNavigation } from '@nextcloud/vue'
|
||||
import { mapState } from 'pinia'
|
||||
import { usePlayer } from '../../store/player.ts'
|
||||
|
||||
export default {
|
||||
name: 'AppNavigation',
|
||||
components: {
|
||||
NcAppNavigation,
|
||||
},
|
||||
computed: {
|
||||
...mapState(usePlayer, ['episode']),
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.episode {
|
||||
padding-bottom: 6rem;
|
||||
}
|
||||
</style>
|
34
src/components/Atoms/EmptyContent.vue
Normal file
34
src/components/Atoms/EmptyContent.vue
Normal file
@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<NcEmptyContent class="empty">
|
||||
<slot />
|
||||
<template #icon>
|
||||
<slot name="icon" />
|
||||
</template>
|
||||
<template #name>
|
||||
<slot name="name" />
|
||||
</template>
|
||||
<template #description>
|
||||
<slot name="description" />
|
||||
</template>
|
||||
<template #action>
|
||||
<slot name="action" />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { NcEmptyContent } from '@nextcloud/vue'
|
||||
|
||||
export default {
|
||||
name: 'EmptyContent',
|
||||
components: {
|
||||
NcEmptyContent,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.empty {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
@ -2,7 +2,7 @@
|
||||
<NcLoadingIcon class="loading" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { NcLoadingIcon } from '@nextcloud/vue'
|
||||
|
||||
export default {
|
||||
@ -14,7 +14,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading {
|
||||
margin: 2rem 0;
|
||||
}
|
||||
.loading {
|
||||
margin: 2rem 0;
|
||||
}
|
||||
</style>
|
||||
|
73
src/components/Atoms/Modal.vue
Normal file
73
src/components/Atoms/Modal.vue
Normal file
@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="flex">
|
||||
<NcAvatar
|
||||
:display-name="episode.name"
|
||||
:is-no-user="true"
|
||||
:size="256"
|
||||
:url="episode.image" />
|
||||
<h2>{{ episode.name }}</h2>
|
||||
<SafeHtml :source="episode.description || ''" />
|
||||
<div class="flex">
|
||||
<NcButton v-if="episode.link" :href="episode.link" target="_blank">
|
||||
<template #icon>
|
||||
<OpenInNewIcon :size="20" />
|
||||
</template>
|
||||
{{ episode.title }}
|
||||
</NcButton>
|
||||
<NcButton
|
||||
v-if="episode.url"
|
||||
:download="filenameFromUrl(episode.url)"
|
||||
:href="episode.url"
|
||||
target="_blank">
|
||||
<template #icon>
|
||||
<DownloadIcon :size="20" />
|
||||
</template>
|
||||
{{ t('repod', 'Download') }}
|
||||
{{ episode.size ? `(${humanFileSize(episode.size)})` : '' }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
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'
|
||||
|
||||
export default {
|
||||
name: 'Modal',
|
||||
components: {
|
||||
DownloadIcon,
|
||||
NcAvatar,
|
||||
NcButton,
|
||||
OpenInNewIcon,
|
||||
SafeHtml,
|
||||
},
|
||||
props: {
|
||||
episode: {
|
||||
type: Object as () => EpisodeInterface,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
filenameFromUrl,
|
||||
humanFileSize,
|
||||
t,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.flex {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin: 2rem;
|
||||
}
|
||||
</style>
|
36
src/components/Atoms/SafeHtml.vue
Normal file
36
src/components/Atoms/SafeHtml.vue
Normal file
@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div v-sanitize="source" class="html" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import dompurify from 'dompurify'
|
||||
import linkifyHtml from 'linkify-html'
|
||||
|
||||
export default {
|
||||
name: 'SafeHtml',
|
||||
directives: {
|
||||
sanitize: {
|
||||
mounted(el, binding) {
|
||||
el.innerHTML = dompurify.sanitize(
|
||||
linkifyHtml(binding.value, {
|
||||
nl2br: true,
|
||||
target: '_blank',
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
props: {
|
||||
source: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.html a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
@ -1,34 +1,37 @@
|
||||
<template>
|
||||
<ul class="bar">
|
||||
<NcAppNavigationNewItem :name="t('repod', 'Add a RSS link')"
|
||||
@new-item="addSubscription">
|
||||
<NcAppNavigationList class="list">
|
||||
<NcAppNavigationNewItem
|
||||
:name="t('repod', 'Add a RSS link')"
|
||||
@new-item="(url) => $router.push(toFeedUrl(url))">
|
||||
<template #icon>
|
||||
<Plus :size="20" />
|
||||
<PlusIcon :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationNewItem>
|
||||
</ul>
|
||||
</NcAppNavigationList>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { NcAppNavigationNewItem } from '@nextcloud/vue'
|
||||
import Plus from 'vue-material-design-icons/Plus.vue'
|
||||
<script lang="ts">
|
||||
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'
|
||||
|
||||
export default {
|
||||
name: 'AddRss',
|
||||
components: {
|
||||
NcAppNavigationList,
|
||||
NcAppNavigationNewItem,
|
||||
Plus,
|
||||
PlusIcon,
|
||||
},
|
||||
methods: {
|
||||
addSubscription(feedUrl) {
|
||||
this.$router.push(btoa(feedUrl))
|
||||
},
|
||||
t,
|
||||
toFeedUrl,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bar {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.list {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
</style>
|
||||
|
@ -1,42 +1,61 @@
|
||||
<template>
|
||||
<div>
|
||||
<Loading v-if="loading" />
|
||||
<AdaptativeList v-if="!loading">
|
||||
<NcListItem v-for="feed in feeds"
|
||||
<ul v-if="!loading">
|
||||
<NcListItem
|
||||
v-for="feed in feeds"
|
||||
:key="feed.link"
|
||||
:details="formatDistanceToNow(new Date(feed.fetchedAtUnix*1000))"
|
||||
:details="formatLocaleDate(new Date(feed.fetchedAtUnix * 1000))"
|
||||
:name="feed.title"
|
||||
:to="toUrl(feed.link)">
|
||||
:to="toFeedUrl(feed.link)">
|
||||
<template #icon>
|
||||
<NcAvatar :display-name="feed.author"
|
||||
<NcAvatar
|
||||
:display-name="feed.author"
|
||||
:is-no-user="true"
|
||||
:url="feed.imageUrl" />
|
||||
</template>
|
||||
<template #subname>
|
||||
{{ feed.author }}
|
||||
</template>
|
||||
<template #actions>
|
||||
<NcActionButton
|
||||
v-if="!getSubByUrl(feed.link)"
|
||||
:aria-label="t('repod', 'Subscribe')"
|
||||
:name="t('repod', 'Subscribe')"
|
||||
:title="t('repod', 'Subscribe')"
|
||||
@click="addSubscription(feed.link)">
|
||||
<template #icon>
|
||||
<PlusIcon :size="20" />
|
||||
</template>
|
||||
</NcActionButton>
|
||||
</template>
|
||||
</NcListItem>
|
||||
</AdaptativeList>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { NcAvatar, NcListItem } from '@nextcloud/vue'
|
||||
import AdaptativeList from '../Atoms/AdaptativeList.vue'
|
||||
<script lang="ts">
|
||||
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 { debounce } from '../../utils/debounce.js'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { formatLocaleDate } from '../../utils/time.ts'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
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: 'Search',
|
||||
components: {
|
||||
AdaptativeList,
|
||||
Loading,
|
||||
NcActionButton,
|
||||
NcAvatar,
|
||||
NcListItem,
|
||||
PlusIcon,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
@ -44,26 +63,55 @@ export default {
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
feeds: [],
|
||||
loading: false,
|
||||
}
|
||||
data: () => ({
|
||||
feeds: [] as PodcastDataInterface[],
|
||||
loading: false,
|
||||
timeout: null as NodeJS.Timeout | null,
|
||||
}),
|
||||
computed: {
|
||||
...mapState(useSubscriptions, ['getSubByUrl']),
|
||||
},
|
||||
watch: {
|
||||
value() {
|
||||
this.search()
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout)
|
||||
}
|
||||
this.timeout = setTimeout(this.search, 200)
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
formatDistanceToNow,
|
||||
search: debounce(async function value() {
|
||||
...mapActions(useSubscriptions, ['fetch']),
|
||||
formatLocaleDate,
|
||||
t,
|
||||
toFeedUrl,
|
||||
async addSubscription(url: string) {
|
||||
try {
|
||||
await axios.post(
|
||||
generateUrl('/apps/gpoddersync/subscription_change/create'),
|
||||
{
|
||||
add: [url],
|
||||
remove: [],
|
||||
},
|
||||
)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
showError(t('repod', 'Error while adding the feed'))
|
||||
}
|
||||
this.fetch()
|
||||
},
|
||||
async search() {
|
||||
try {
|
||||
this.loading = true
|
||||
const currentSearch = this.value
|
||||
const feeds = await axios.get(generateUrl('/apps/repod/search?value={value}', { value: currentSearch }))
|
||||
const feeds = await axios.get<PodcastDataInterface[]>(
|
||||
generateUrl('/apps/repod/search?q={value}', {
|
||||
value: currentSearch,
|
||||
}),
|
||||
)
|
||||
if (currentSearch === this.value) {
|
||||
this.feeds = feeds.data
|
||||
this.feeds = [...feeds.data].sort(
|
||||
(a, b) => b.fetchedAtUnix - a.fetchedAtUnix,
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
@ -73,9 +121,6 @@ export default {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
}, 200),
|
||||
toUrl(url) {
|
||||
return `/${btoa(url)}`
|
||||
},
|
||||
},
|
||||
}
|
||||
|
@ -1,41 +0,0 @@
|
||||
<template>
|
||||
<router-link :to="toUrl">
|
||||
<img :src="imageUrl" :title="author">
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'TopItem',
|
||||
props: {
|
||||
author: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
imageUrl: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
link: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
toUrl() {
|
||||
return `/${btoa(this.link)}`
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
img {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
@ -1,95 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div>
|
||||
<h2>{{ t('repod', 'Hot podcasts') }}</h2>
|
||||
<Loading v-if="tops.hot.loading" />
|
||||
<ul v-if="!tops.hot.loading">
|
||||
<li v-for="top in tops.hot.items" :key="top.link">
|
||||
<TopItem :author="top.author"
|
||||
:image-url="top.imageUrl"
|
||||
:link="top.link"
|
||||
:title="top.title" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h2>{{ t('repod', 'New podcasts') }}</h2>
|
||||
<Loading v-if="tops.new.loading" />
|
||||
<ul v-if="!tops.new.loading">
|
||||
<li v-for="top in tops.new.items" :key="top.link">
|
||||
<TopItem :author="top.author"
|
||||
:image-url="top.imageUrl"
|
||||
:link="top.link"
|
||||
:title="top.title" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Loading from '../Atoms/Loading.vue'
|
||||
import TopItem from './TopItem.vue'
|
||||
import axios from '@nextcloud/axios'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
|
||||
export default {
|
||||
name: 'TopList',
|
||||
components: {
|
||||
Loading,
|
||||
TopItem,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tops: {
|
||||
hot: {
|
||||
items: [],
|
||||
loading: true,
|
||||
},
|
||||
new: {
|
||||
items: [],
|
||||
loading: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
this.loadList('hot')
|
||||
this.loadList('new')
|
||||
},
|
||||
methods: {
|
||||
async loadList(type) {
|
||||
try {
|
||||
this.tops[type].loading = true
|
||||
const toplist = await axios.get(generateUrl(`/apps/repod/toplist/${type}`))
|
||||
this.tops[type].items = toplist.data
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
showError(t('repod', 'Could not fetch tops'))
|
||||
} finally {
|
||||
this.tops[type].loading = false
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
h2 {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
li {
|
||||
flex-basis: 10rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding: .5rem;
|
||||
}
|
||||
</style>
|
92
src/components/Discover/Toplist.vue
Normal file
92
src/components/Discover/Toplist.vue
Normal file
@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>{{ title }}</h2>
|
||||
<Loading v-if="loading" />
|
||||
<ul v-if="!loading">
|
||||
<li v-for="top in tops" :key="top.link">
|
||||
<router-link :to="toFeedUrl(top.link)">
|
||||
<img :src="top.imageUrl" :title="top.author" />
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Loading from '../Atoms/Loading.vue'
|
||||
import type { PodcastDataInterface } from '../../utils/types.ts'
|
||||
import axios from '@nextcloud/axios'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { showError } from '../../utils/toast.ts'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { toFeedUrl } from '../../utils/url.ts'
|
||||
|
||||
export default {
|
||||
name: 'Toplist',
|
||||
components: {
|
||||
Loading,
|
||||
},
|
||||
props: {
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data: () => ({
|
||||
loading: true,
|
||||
tops: [] as PodcastDataInterface[],
|
||||
}),
|
||||
computed: {
|
||||
title() {
|
||||
switch (this.type) {
|
||||
case 'new':
|
||||
return t('repod', 'New podcasts')
|
||||
case 'hot':
|
||||
return t('repod', 'Hot podcasts')
|
||||
default:
|
||||
return this.type
|
||||
}
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
try {
|
||||
this.loading = true
|
||||
const tops = await axios.get<PodcastDataInterface[]>(
|
||||
generateUrl('/apps/repod/toplist/{type}', { type: this.type }),
|
||||
)
|
||||
this.tops = tops.data
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
showError(t('repod', 'Could not fetch tops'))
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toFeedUrl,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
h2 {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
img {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
li {
|
||||
flex-basis: 10rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
overflow: scroll hidden;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
@ -1,28 +1,33 @@
|
||||
<template>
|
||||
<div class="header">
|
||||
<img class="background" :src="imageUrl">
|
||||
<img class="background" :src="feed.imageUrl" />
|
||||
<div class="content">
|
||||
<NcAvatar class="avatar"
|
||||
:display-name="author"
|
||||
:is-no-user="true"
|
||||
:size="128"
|
||||
:url="imageUrl" />
|
||||
<div>
|
||||
<NcAvatar
|
||||
:display-name="feed.author || feed.title"
|
||||
:is-no-user="true"
|
||||
:size="128"
|
||||
:url="feed.imageUrl" />
|
||||
<a class="feed" :href="url" @click.prevent="copyFeed">
|
||||
<RssIcon :size="20" />
|
||||
<i>{{ t('repod', 'Copy feed') }}</i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="inner">
|
||||
<div class="infos">
|
||||
<h2>{{ title }}</h2>
|
||||
<a :href="link" target="_blank">
|
||||
<i>{{ author }}</i>
|
||||
<h2>{{ feed.title }}</h2>
|
||||
<a :href="feed.link" target="_blank">
|
||||
<i>{{ feed.author }}</i>
|
||||
</a>
|
||||
<br><br>
|
||||
<p>
|
||||
<small>{{ strippedDescription }}</small>
|
||||
</p>
|
||||
<br /><br />
|
||||
<SafeHtml :source="feed.description || ''" />
|
||||
</div>
|
||||
<NcAppNavigationNew v-if="!isSubscribed"
|
||||
<NcAppNavigationNew
|
||||
v-if="!getSubByUrl(url)"
|
||||
:text="t('repod', 'Subscribe')"
|
||||
@click="addSubscription">
|
||||
<template #icon>
|
||||
<Plus :size="20" />
|
||||
<PlusIcon :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationNew>
|
||||
</div>
|
||||
@ -30,111 +35,112 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { NcAppNavigationNew, NcAvatar } from '@nextcloud/vue'
|
||||
import Plus from 'vue-material-design-icons/Plus.vue'
|
||||
import { mapActions, mapState } from 'pinia'
|
||||
import { showError, showSuccess } from '../../utils/toast.ts'
|
||||
import PlusIcon from 'vue-material-design-icons/Plus.vue'
|
||||
import type { PodcastDataInterface } from '../../utils/types.ts'
|
||||
import RssIcon from 'vue-material-design-icons/Rss.vue'
|
||||
import SafeHtml from '../Atoms/SafeHtml.vue'
|
||||
import axios from '@nextcloud/axios'
|
||||
import { decodeUrl } from '../../utils/url.ts'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { useSubscriptions } from '../../store/subscriptions.ts'
|
||||
|
||||
export default {
|
||||
name: 'Banner',
|
||||
components: {
|
||||
NcAvatar,
|
||||
NcAppNavigationNew,
|
||||
Plus,
|
||||
PlusIcon,
|
||||
RssIcon,
|
||||
SafeHtml,
|
||||
},
|
||||
props: {
|
||||
author: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
imageUrl: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
link: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
feed: {
|
||||
type: Object as () => PodcastDataInterface,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapState(useSubscriptions, ['getSubByUrl']),
|
||||
url() {
|
||||
return atob(this.$route.params.url)
|
||||
},
|
||||
isSubscribed() {
|
||||
return this.$store.state.subscriptions.subscriptions.includes(this.url)
|
||||
},
|
||||
strippedDescription() {
|
||||
const pre = document.createElement('pre')
|
||||
pre.innerHTML = this.description
|
||||
return pre.textContent || pre.innerText || ''
|
||||
return decodeUrl(this.$route.params.url as string)
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useSubscriptions, ['fetch']),
|
||||
t,
|
||||
async addSubscription() {
|
||||
try {
|
||||
await axios.post(generateUrl('/apps/gpoddersync/subscription_change/create'), { add: [this.url], remove: [] })
|
||||
await axios.post(
|
||||
generateUrl('/apps/gpoddersync/subscription_change/create'),
|
||||
{
|
||||
add: [this.url],
|
||||
remove: [],
|
||||
},
|
||||
)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
showError(t('repod', 'Error while adding the feed'))
|
||||
}
|
||||
|
||||
this.$store.dispatch('subscriptions/fetch')
|
||||
this.fetch()
|
||||
},
|
||||
copyFeed() {
|
||||
window.navigator.clipboard.writeText(this.url)
|
||||
showSuccess(t('repod', 'Link copied to the clipboard'))
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.avatar {
|
||||
height: 8rem;
|
||||
width: 8rem;
|
||||
}
|
||||
.background {
|
||||
filter: blur(1rem) brightness(50%);
|
||||
height: auto;
|
||||
left: 0;
|
||||
opacity: 0.4;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.background {
|
||||
filter: blur(1rem) brightness(50%);
|
||||
height: auto;
|
||||
left: 0;
|
||||
opacity: .4;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
height: 10rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
height: 10rem;
|
||||
position: relative;
|
||||
}
|
||||
.feed {
|
||||
display: flex;
|
||||
gap: 0.2rem;
|
||||
margin: 0.5rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
height: 14rem;
|
||||
overflow: hidden;
|
||||
padding: 2rem;
|
||||
position: relative;
|
||||
}
|
||||
.header {
|
||||
height: 14rem;
|
||||
overflow: hidden;
|
||||
padding: 2rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.infos {
|
||||
overflow: auto;
|
||||
}
|
||||
.infos {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.inner {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.inner {
|
||||
flex-direction: column;
|
||||
}
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
222
src/components/Feed/Episode.vue
Normal file
222
src/components/Feed/Episode.vue
Normal file
@ -0,0 +1,222 @@
|
||||
<template>
|
||||
<NcListItem
|
||||
:active="isCurrentEpisode(episode)"
|
||||
class="episode"
|
||||
:details="
|
||||
!oneLine && episode.pubDate
|
||||
? formatLocaleDate(new Date(episode.pubDate?.date))
|
||||
: ''
|
||||
"
|
||||
:force-display-actions="true"
|
||||
:name="episode.name"
|
||||
:one-line="oneLine"
|
||||
:style="{ opacity: hasEnded(episode) ? 0.4 : 1 }"
|
||||
:title="episode.description"
|
||||
@click="modalEpisode = episode">
|
||||
<template #actions>
|
||||
<NcActionButton
|
||||
v-if="!isCurrentEpisode(episode)"
|
||||
:aria-label="t('repod', 'Play')"
|
||||
:title="t('repod', 'Play')"
|
||||
@click="load(episode, url)">
|
||||
<template #icon>
|
||||
<PlayIcon :size="20" />
|
||||
</template>
|
||||
</NcActionButton>
|
||||
<NcActionButton
|
||||
v-if="isCurrentEpisode(episode)"
|
||||
:aria-label="t('repod', 'Stop')"
|
||||
:title="t('repod', 'Stop')"
|
||||
@click="load(null)">
|
||||
<template #icon>
|
||||
<StopIcon :size="20" />
|
||||
</template>
|
||||
</NcActionButton>
|
||||
</template>
|
||||
<template #extra>
|
||||
<NcActions>
|
||||
<NcActionButton
|
||||
v-if="episode.duration"
|
||||
:aria-label="t('repod', 'Read')"
|
||||
:disabled="loading"
|
||||
:model-value="hasEnded(episode)"
|
||||
:name="t('repod', 'Read')"
|
||||
:title="t('repod', 'Read')"
|
||||
@click="markAs(episode, !hasEnded(episode))">
|
||||
<template #icon>
|
||||
<PlaylistPlayIcon v-if="!hasEnded(episode)" :size="20" />
|
||||
<PlaylistRemoveIcon v-if="hasEnded(episode)" :size="20" />
|
||||
</template>
|
||||
</NcActionButton>
|
||||
<NcActionLink
|
||||
v-if="episode.link"
|
||||
:href="episode.link"
|
||||
:name="t('repod', 'Open website')"
|
||||
target="_blank"
|
||||
:title="t('repod', 'Open website')">
|
||||
<template #icon>
|
||||
<OpenInNewIcon :size="20" />
|
||||
</template>
|
||||
</NcActionLink>
|
||||
<NcActionLink
|
||||
v-if="episode.url"
|
||||
:download="filenameFromUrl(episode.url)"
|
||||
:href="episode.url"
|
||||
:name="t('repod', 'Download')"
|
||||
target="_blank"
|
||||
:title="t('repod', 'Download')">
|
||||
<template #icon>
|
||||
<DownloadIcon :size="20" />
|
||||
</template>
|
||||
</NcActionLink>
|
||||
</NcActions>
|
||||
<NcModal v-if="modalEpisode" @close="modalEpisode = null">
|
||||
<Modal :episode="episode" />
|
||||
</NcModal>
|
||||
</template>
|
||||
<template #icon>
|
||||
<NcAvatar
|
||||
:display-name="episode.name"
|
||||
:is-no-user="true"
|
||||
:url="episode.image" />
|
||||
</template>
|
||||
<template #indicator>
|
||||
<NcProgressBar
|
||||
v-if="episode.action && isListening(episode) && !oneLine"
|
||||
class="progress"
|
||||
:value="(episode.action.position * 100) / episode.action.total" />
|
||||
</template>
|
||||
<template #subname>
|
||||
{{ episode.duration }}
|
||||
</template>
|
||||
</NcListItem>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
NcActionButton,
|
||||
NcActionLink,
|
||||
NcActions,
|
||||
NcAvatar,
|
||||
NcListItem,
|
||||
NcModal,
|
||||
NcProgressBar,
|
||||
} from '@nextcloud/vue'
|
||||
import {
|
||||
durationToSeconds,
|
||||
formatEpisodeTimestamp,
|
||||
formatLocaleDate,
|
||||
} from '../../utils/time.ts'
|
||||
import { hasEnded, isListening } from '../../utils/status.ts'
|
||||
import { mapActions, mapState } from 'pinia'
|
||||
import DownloadIcon from 'vue-material-design-icons/Download.vue'
|
||||
import type { EpisodeInterface } from '../../utils/types.ts'
|
||||
import Modal from '../Atoms/Modal.vue'
|
||||
import OpenInNewIcon from 'vue-material-design-icons/OpenInNew.vue'
|
||||
import PlayIcon from 'vue-material-design-icons/Play.vue'
|
||||
import PlaylistPlayIcon from 'vue-material-design-icons/PlaylistPlay.vue'
|
||||
import PlaylistRemoveIcon from 'vue-material-design-icons/PlaylistRemove.vue'
|
||||
import StopIcon from 'vue-material-design-icons/Stop.vue'
|
||||
import axios from '@nextcloud/axios'
|
||||
import { filenameFromUrl } from '../../utils/url.ts'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { showError } from '../../utils/toast.ts'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { usePlayer } from '../../store/player.ts'
|
||||
|
||||
export default {
|
||||
name: 'Episode',
|
||||
components: {
|
||||
DownloadIcon,
|
||||
Modal,
|
||||
NcActionButton,
|
||||
NcActionLink,
|
||||
NcActions,
|
||||
NcAvatar,
|
||||
NcListItem,
|
||||
NcModal,
|
||||
NcProgressBar,
|
||||
OpenInNewIcon,
|
||||
PlayIcon,
|
||||
PlaylistPlayIcon,
|
||||
PlaylistRemoveIcon,
|
||||
StopIcon,
|
||||
},
|
||||
props: {
|
||||
episode: {
|
||||
type: Object as () => EpisodeInterface,
|
||||
required: true,
|
||||
},
|
||||
oneLine: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
url: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data: () => ({
|
||||
loading: false,
|
||||
modalEpisode: null as EpisodeInterface | null,
|
||||
}),
|
||||
computed: {
|
||||
...mapState(usePlayer, { playerEpisode: 'episode' }),
|
||||
},
|
||||
methods: {
|
||||
...mapActions(usePlayer, ['load']),
|
||||
filenameFromUrl,
|
||||
formatLocaleDate,
|
||||
hasEnded,
|
||||
isListening,
|
||||
t,
|
||||
isCurrentEpisode(episode: EpisodeInterface) {
|
||||
return this.playerEpisode?.url === episode.url
|
||||
},
|
||||
async markAs(episode: EpisodeInterface, read: boolean) {
|
||||
try {
|
||||
this.loading = true
|
||||
episode.action = {
|
||||
podcast: this.url,
|
||||
episode: episode.url,
|
||||
guid: episode.guid,
|
||||
action: 'play',
|
||||
timestamp: formatEpisodeTimestamp(new Date()),
|
||||
started: episode.action?.started || 0,
|
||||
position: read ? durationToSeconds(episode.duration || '') : 0,
|
||||
total: durationToSeconds(episode.duration || ''),
|
||||
}
|
||||
await axios.post(
|
||||
generateUrl('/apps/gpoddersync/episode_action/create'),
|
||||
[episode.action],
|
||||
)
|
||||
if (read && this.isCurrentEpisode(episode)) {
|
||||
this.load(null)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
showError(t('repod', 'Could not change the status of the episode'))
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.progress {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.episode .list-item-content__name {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.episode .list-item-content__subname {
|
||||
flex-basis: auto;
|
||||
flex-grow: 0;
|
||||
}
|
||||
</style>
|
@ -1,98 +1,83 @@
|
||||
<template>
|
||||
<div>
|
||||
<Loading v-if="loading" />
|
||||
<AdaptativeList v-if="!loading">
|
||||
<NcListItem v-for="episode in episodes"
|
||||
<ul v-if="!loading">
|
||||
<Episode
|
||||
v-for="episode in filteredEpisodes"
|
||||
:key="episode.guid"
|
||||
:active="isCurrentEpisode(episode)"
|
||||
:class="episode.action && episode.action.position >= episode.action.total ? 'ended': ''"
|
||||
:details="formatDistanceToNow(new Date(episode.pubDate.date))"
|
||||
:force-display-actions="true"
|
||||
:name="episode.name"
|
||||
:title="episode.description"
|
||||
@click="modalEpisode = episode">
|
||||
<template #icon>
|
||||
<NcAvatar :display-name="episode.name"
|
||||
:is-no-user="true"
|
||||
:url="episode.image" />
|
||||
</template>
|
||||
<template #subname>
|
||||
{{ formatTimer(new Date(episode.duration*1000)) }}
|
||||
</template>
|
||||
<template #actions>
|
||||
<NcActionButton v-if="!isCurrentEpisode(episode)" @click="load(episode)">
|
||||
<template #icon>
|
||||
<PlayButton :size="20" />
|
||||
</template>
|
||||
{{ t('repod', 'Play') }}
|
||||
</NcActionButton>
|
||||
<NcActionButton v-if="isCurrentEpisode(episode)" @click="load(null)">
|
||||
<template #icon>
|
||||
<StopButton :size="20" />
|
||||
</template>
|
||||
{{ t('repod', 'Stop') }}
|
||||
</NcActionButton>
|
||||
</template>
|
||||
</NcListItem>
|
||||
</AdaptativeList>
|
||||
<NcModal v-if="modalEpisode"
|
||||
@close="modalEpisode = null">
|
||||
<Modal :description="modalEpisode.description"
|
||||
:image="modalEpisode.image"
|
||||
:link="modalEpisode.link"
|
||||
:name="modalEpisode.name"
|
||||
:podcast="modalEpisode.podcast"
|
||||
:url="modalEpisode.url" />
|
||||
</NcModal>
|
||||
:episode="episode"
|
||||
:url="url" />
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { NcActionButton, NcAvatar, NcListItem, NcModal } from '@nextcloud/vue'
|
||||
import AdaptativeList from '../Atoms/AdaptativeList.vue'
|
||||
<script lang="ts">
|
||||
import { hasEnded, isListening } from '../../utils/status.ts'
|
||||
import Episode from './Episode.vue'
|
||||
import type { EpisodeInterface } from '../../utils/types.ts'
|
||||
import Loading from '../Atoms/Loading.vue'
|
||||
import Modal from './Modal.vue'
|
||||
import PlayButton from 'vue-material-design-icons/Play.vue'
|
||||
import StopButton from 'vue-material-design-icons/Stop.vue'
|
||||
import axios from '@nextcloud/axios'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { formatTimer } from '../../utils/time.js'
|
||||
import { decodeUrl } from '../../utils/url.ts'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { mapState } from 'pinia'
|
||||
import { showError } from '../../utils/toast.ts'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { usePlayer } from '../../store/player.ts'
|
||||
import { useSettings } from '../../store/settings.ts'
|
||||
|
||||
export default {
|
||||
name: 'Episodes',
|
||||
components: {
|
||||
AdaptativeList,
|
||||
Episode,
|
||||
Loading,
|
||||
Modal,
|
||||
NcActionButton,
|
||||
NcAvatar,
|
||||
NcListItem,
|
||||
NcModal,
|
||||
PlayButton,
|
||||
StopButton,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
episodes: [],
|
||||
loading: true,
|
||||
modalEpisode: null,
|
||||
}
|
||||
},
|
||||
data: () => ({
|
||||
episodes: [] as EpisodeInterface[],
|
||||
loading: true,
|
||||
}),
|
||||
computed: {
|
||||
currentEpisode() {
|
||||
return this.$store.state.player.episode
|
||||
...mapState(usePlayer, ['episode']),
|
||||
...mapState(useSettings, ['filters']),
|
||||
filteredEpisodes() {
|
||||
return this.episodes.filter((episode) => {
|
||||
if (!this.filters.listened && this.hasEnded(episode)) {
|
||||
return false
|
||||
}
|
||||
if (!this.filters.listening && this.isListening(episode)) {
|
||||
return false
|
||||
}
|
||||
if (!this.filters.unlistened && !this.isListening(episode)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
},
|
||||
url() {
|
||||
return atob(this.$route.params.url)
|
||||
return decodeUrl(this.$route.params.url as string)
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
episode() {
|
||||
if (this.episode) {
|
||||
this.episodes = this.episodes.map((e) =>
|
||||
e.url === this.episode?.url ? this.episode : e,
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
try {
|
||||
this.loading = true
|
||||
const episodes = await axios.get(generateUrl('/apps/repod/episodes/list?url={url}', { url: this.url }))
|
||||
this.episodes = episodes.data
|
||||
const episodes = await axios.get<EpisodeInterface[]>(
|
||||
generateUrl('/apps/repod/episodes/list?url={url}', {
|
||||
url: this.url,
|
||||
}),
|
||||
)
|
||||
this.episodes = [...episodes.data].sort(
|
||||
(a, b) =>
|
||||
new Date(b.pubDate?.date || '').getTime() -
|
||||
new Date(a.pubDate?.date || '').getTime(),
|
||||
)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
showError(t('repod', 'Could not fetch episodes'))
|
||||
@ -101,20 +86,8 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
formatTimer,
|
||||
formatDistanceToNow,
|
||||
isCurrentEpisode(episode) {
|
||||
return this.currentEpisode && this.currentEpisode.url === episode.url
|
||||
},
|
||||
load(episode) {
|
||||
this.$store.dispatch('player/load', episode)
|
||||
},
|
||||
hasEnded,
|
||||
isListening,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ended {
|
||||
opacity: .5;
|
||||
}
|
||||
</style>
|
||||
|
106
src/components/Feed/Favorite.vue
Normal file
106
src/components/Feed/Favorite.vue
Normal file
@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<NcGuestContent class="guest">
|
||||
<Loading v-if="!feed.data" />
|
||||
<NcAvatar
|
||||
v-if="feed.data"
|
||||
class="avatar"
|
||||
:display-name="feed.data.author || feed.data.title"
|
||||
:is-no-user="true"
|
||||
:size="222"
|
||||
:title="feed.data.author"
|
||||
:url="feed.data.imageUrl" />
|
||||
<div v-if="feed.data" class="list">
|
||||
<h2 class="title">{{ feed.data.title }}</h2>
|
||||
<Loading v-if="loading" />
|
||||
<ul v-if="!loading">
|
||||
<Episode
|
||||
v-for="episode in episodes"
|
||||
:key="episode.guid"
|
||||
:episode="episode"
|
||||
:one-line="true"
|
||||
:url="feed.metrics.url" />
|
||||
</ul>
|
||||
</div>
|
||||
</NcGuestContent>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { EpisodeInterface, SubscriptionInterface } from '../../utils/types.ts'
|
||||
import { NcAvatar, NcGuestContent } from '@nextcloud/vue'
|
||||
import Episode from './Episode.vue'
|
||||
import Loading from '../Atoms/Loading.vue'
|
||||
import axios from '@nextcloud/axios'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { hasEnded } from '../../utils/status.ts'
|
||||
import { showError } from '../../utils/toast.ts'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
|
||||
export default {
|
||||
name: 'Favorite',
|
||||
components: {
|
||||
Episode,
|
||||
Loading,
|
||||
NcAvatar,
|
||||
NcGuestContent,
|
||||
},
|
||||
props: {
|
||||
feed: {
|
||||
type: Object as () => SubscriptionInterface,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data: () => ({
|
||||
episodes: [] as EpisodeInterface[],
|
||||
loading: true,
|
||||
}),
|
||||
async mounted() {
|
||||
try {
|
||||
this.loading = true
|
||||
const episodes = await axios.get<EpisodeInterface[]>(
|
||||
generateUrl('/apps/repod/episodes/list?url={url}', {
|
||||
url: this.feed.metrics.url,
|
||||
}),
|
||||
)
|
||||
this.episodes = [...episodes.data]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.pubDate?.date || '').getTime() -
|
||||
new Date(a.pubDate?.date || '').getTime(),
|
||||
)
|
||||
.filter((episode) => !this.hasEnded(episode))
|
||||
.slice(0, 4)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
showError(t('repod', 'Could not fetch episodes'))
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
hasEnded,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.guest {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin: 20px !important;
|
||||
}
|
||||
|
||||
.list {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.avatar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,90 +0,0 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div class="content">
|
||||
<NcAvatar :display-name="name"
|
||||
:is-no-user="true"
|
||||
size="256"
|
||||
:url="image" />
|
||||
<h2>{{ name }}</h2>
|
||||
<p v-html="strippedDescription" />
|
||||
<div class="buttons">
|
||||
<NcButton v-if="link" :href="link" target="_blank">
|
||||
<template #icon>
|
||||
<OpenInNew :size="20" />
|
||||
</template>
|
||||
{{ podcastName }}
|
||||
</NcButton>
|
||||
<NcButton v-if="url" :href="url" target="_blank">
|
||||
<template #icon>
|
||||
<Download :size="20" />
|
||||
</template>
|
||||
{{ t('repod', 'Download') }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { NcAvatar, NcButton } from '@nextcloud/vue'
|
||||
import Download from 'vue-material-design-icons/Download.vue'
|
||||
import OpenInNew from 'vue-material-design-icons/OpenInNew.vue'
|
||||
|
||||
export default {
|
||||
name: 'Modal',
|
||||
components: {
|
||||
Download,
|
||||
NcAvatar,
|
||||
NcButton,
|
||||
OpenInNew,
|
||||
},
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
image: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
url: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
link: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
podcast: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
strippedDescription() {
|
||||
const pre = document.createElement('pre')
|
||||
pre.innerHTML = this.description
|
||||
const strippedDescription = pre.textContent || pre.innerText || ''
|
||||
return strippedDescription.replace(/\n/g, '<br>')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
margin: 2rem;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
@ -1,9 +1,10 @@
|
||||
<template>
|
||||
<div v-if="player.episode" class="footer">
|
||||
<Loading v-if="!player.loaded" />
|
||||
<ProgressBar v-if="player.loaded" />
|
||||
<div v-if="player.loaded" class="player">
|
||||
<img :src="player.episode.image">
|
||||
<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" />
|
||||
<Infos class="infos" />
|
||||
<Controls class="controls" />
|
||||
<Timer class="timer" />
|
||||
@ -12,13 +13,15 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
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',
|
||||
@ -31,46 +34,56 @@ export default {
|
||||
Volume,
|
||||
},
|
||||
computed: {
|
||||
player() {
|
||||
return this.$store.state.player
|
||||
},
|
||||
...mapState(usePlayer, ['episode', 'loaded']),
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.footer {
|
||||
background-color: rgba(66, 66, 66, .9);
|
||||
bottom: 0;
|
||||
height: 6rem;
|
||||
right: 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.player {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
height: calc(6rem - 6px);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.timer {
|
||||
width: 30%;
|
||||
.background {
|
||||
filter: blur(1rem) brightness(50%);
|
||||
height: auto;
|
||||
left: 0;
|
||||
opacity: 0.4;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background-color: var(--color-main-background);
|
||||
bottom: 0;
|
||||
height: 6rem;
|
||||
right: 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.player {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
height: 6rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.timer {
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
.volume {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.infos {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.timer,
|
||||
.volume {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.infos {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
.timer, .volume {
|
||||
display: none;
|
||||
}
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -1,40 +1,70 @@
|
||||
<template>
|
||||
<div class="controls">
|
||||
<PauseButton v-if="!player.paused"
|
||||
<Rewind10Icon
|
||||
class="pointer rewind"
|
||||
:size="20"
|
||||
:title="t('repod', 'Rewind 10 seconds')"
|
||||
@click="seek((currentTime ?? 0) - 10)" />
|
||||
<PauseIcon
|
||||
v-if="!paused"
|
||||
class="pointer"
|
||||
:size="50"
|
||||
@click="$store.dispatch('player/pause')" />
|
||||
<PlayButton v-if="player.paused"
|
||||
:title="t('repod', 'Pause')"
|
||||
@click="pause" />
|
||||
<PlayIcon
|
||||
v-if="paused"
|
||||
class="pointer"
|
||||
:size="50"
|
||||
@click="$store.dispatch('player/play')" />
|
||||
:title="t('repod', 'Play')"
|
||||
@click="play" />
|
||||
<FastForward30Icon
|
||||
class="pointer forward"
|
||||
:size="20"
|
||||
:title="t('repod', 'Fast forward 30 seconds')"
|
||||
@click="seek((currentTime ?? 0) + 30)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PauseButton from 'vue-material-design-icons/Pause.vue'
|
||||
import PlayButton from 'vue-material-design-icons/Play.vue'
|
||||
<script lang="ts">
|
||||
import { mapActions, mapState } from 'pinia'
|
||||
import FastForward30Icon from 'vue-material-design-icons/FastForward30.vue'
|
||||
import PauseIcon from 'vue-material-design-icons/Pause.vue'
|
||||
import PlayIcon from 'vue-material-design-icons/Play.vue'
|
||||
import Rewind10Icon from 'vue-material-design-icons/Rewind10.vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { usePlayer } from '../../store/player.ts'
|
||||
|
||||
export default {
|
||||
name: 'Controls',
|
||||
components: {
|
||||
PauseButton,
|
||||
PlayButton,
|
||||
FastForward30Icon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
Rewind10Icon,
|
||||
},
|
||||
computed: {
|
||||
player() {
|
||||
return this.$store.state.player
|
||||
},
|
||||
...mapState(usePlayer, ['currentTime', 'paused']),
|
||||
},
|
||||
methods: {
|
||||
...mapActions(usePlayer, ['play', 'pause', 'seek']),
|
||||
t,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.controls {
|
||||
display: flex;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.forward,
|
||||
.rewind {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -1,35 +1,51 @@
|
||||
<template>
|
||||
<div>
|
||||
<a :href="player.episode.link" target="_blank">
|
||||
<strong>{{ player.episode.name }}</strong>
|
||||
</a>
|
||||
<router-link :to="toUrl(player.podcastUrl)">
|
||||
<i>{{ player.episode.podcast }}</i>
|
||||
<div v-if="episode && podcastUrl" class="root">
|
||||
<strong class="pointer" @click="modal = true">
|
||||
{{ episode.name }}
|
||||
</strong>
|
||||
<router-link :to="toFeedUrl(podcastUrl)">
|
||||
<i>{{ episode.title }}</i>
|
||||
</router-link>
|
||||
<NcModal v-if="modal" @close="modal = false">
|
||||
<Modal :episode="episode" />
|
||||
</NcModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
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'
|
||||
|
||||
export default {
|
||||
name: 'Infos',
|
||||
components: {
|
||||
Modal,
|
||||
NcModal,
|
||||
},
|
||||
data: () => ({
|
||||
modal: false,
|
||||
}),
|
||||
computed: {
|
||||
player() {
|
||||
return this.$store.state.player
|
||||
},
|
||||
...mapState(usePlayer, ['episode', 'podcastUrl']),
|
||||
},
|
||||
methods: {
|
||||
toUrl(url) {
|
||||
return `/${btoa(url)}`
|
||||
},
|
||||
toFeedUrl,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
width: 40%;
|
||||
}
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
width: 40%;
|
||||
}
|
||||
</style>
|
||||
|
@ -1,31 +1,37 @@
|
||||
<template>
|
||||
<div @click="(event) => $store.dispatch('player/seek', event.x * player.duration / event.target.offsetWidth)">
|
||||
<NcProgressBar class="bar" :value="player.currentTime * 100 / player.duration" />
|
||||
</div>
|
||||
<input
|
||||
v-if="duration"
|
||||
class="progress"
|
||||
:max="duration"
|
||||
min="0"
|
||||
type="range"
|
||||
:value="currentTime"
|
||||
@change="
|
||||
(event) => seek(parseInt((event.target as HTMLInputElement).value))
|
||||
" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { NcProgressBar } from '@nextcloud/vue'
|
||||
<script lang="ts">
|
||||
import { mapActions, mapState } from 'pinia'
|
||||
import { usePlayer } from '../../store/player.ts'
|
||||
|
||||
export default {
|
||||
name: 'ProgressBar',
|
||||
components: {
|
||||
NcProgressBar,
|
||||
},
|
||||
computed: {
|
||||
player() {
|
||||
return this.$store.state.player
|
||||
},
|
||||
...mapState(usePlayer, ['duration', 'currentTime']),
|
||||
},
|
||||
methods: {
|
||||
...mapActions(usePlayer, ['seek']),
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
div {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 10px !important;
|
||||
}
|
||||
.progress {
|
||||
height: 4px;
|
||||
min-height: 4px;
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
width: 99%;
|
||||
}
|
||||
</style>
|
||||
|
@ -1,20 +1,20 @@
|
||||
<template>
|
||||
<div>
|
||||
<span>{{ formatTimer(new Date(player.currentTime*1000)) }}</span>
|
||||
<div v-if="currentTime && duration" class="root">
|
||||
<span>{{ formatTimer(new Date(currentTime * 1000)) }}</span>
|
||||
<span>/</span>
|
||||
<span>{{ formatTimer(new Date(player.duration*1000)) }}</span>
|
||||
<span>{{ formatTimer(new Date(duration * 1000)) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { formatTimer } from '../../utils/time.js'
|
||||
<script lang="ts">
|
||||
import { formatTimer } from '../../utils/time.ts'
|
||||
import { mapState } from 'pinia'
|
||||
import { usePlayer } from '../../store/player.ts'
|
||||
|
||||
export default {
|
||||
name: 'Timer',
|
||||
computed: {
|
||||
player() {
|
||||
return this.$store.state.player
|
||||
},
|
||||
...mapState(usePlayer, ['duration', 'currentTime']),
|
||||
},
|
||||
methods: {
|
||||
formatTimer,
|
||||
@ -23,10 +23,10 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
div {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
justify-content: center;
|
||||
}
|
||||
.root {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
@ -1,80 +1,90 @@
|
||||
<template>
|
||||
<div>
|
||||
<VolumeHigh v-if="player.volume > 0.7"
|
||||
<VolumeHighIcon
|
||||
v-if="volume > 0.7"
|
||||
class="pointer"
|
||||
:size="30"
|
||||
:title="t('repod', 'Mute')"
|
||||
@click="mute" />
|
||||
<VolumeLow v-if="player.volume > 0 && player.volume <= 0.3"
|
||||
<VolumeLowIcon
|
||||
v-if="volume > 0 && volume <= 0.3"
|
||||
class="pointer"
|
||||
:size="30"
|
||||
:title="t('repod', 'Mute')"
|
||||
@click="mute" />
|
||||
<VolumeMedium v-if="player.volume > 0.3 && player.volume <= 0.7"
|
||||
<VolumeMediumIcon
|
||||
v-if="volume > 0.3 && volume <= 0.7"
|
||||
class="pointer"
|
||||
:size="30"
|
||||
:title="t('repod', 'Mute')"
|
||||
@click="mute" />
|
||||
<VolumeMute v-if="player.volume == 0"
|
||||
<VolumeMuteIcon
|
||||
v-if="volume === 0"
|
||||
class="pointer"
|
||||
:size="30"
|
||||
@click="unmute" />
|
||||
<input max="1"
|
||||
:title="t('repod', 'Unmute')"
|
||||
@click="setVolume(volumeMuted)" />
|
||||
<input
|
||||
max="1"
|
||||
min="0"
|
||||
step="0.1"
|
||||
type="range"
|
||||
:value="player.volume"
|
||||
@change="(event) => $store.dispatch('player/volume', event.target.value)">
|
||||
:value="volume"
|
||||
@change="
|
||||
(event) =>
|
||||
setVolume(parseFloat((event.target as HTMLInputElement).value))
|
||||
" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import VolumeHigh from 'vue-material-design-icons/VolumeHigh.vue'
|
||||
import VolumeLow from 'vue-material-design-icons/VolumeLow.vue'
|
||||
import VolumeMedium from 'vue-material-design-icons/VolumeMedium.vue'
|
||||
import VolumeMute from 'vue-material-design-icons/VolumeMute.vue'
|
||||
<script lang="ts">
|
||||
import { mapActions, mapState } from 'pinia'
|
||||
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',
|
||||
components: {
|
||||
VolumeHigh,
|
||||
VolumeLow,
|
||||
VolumeMedium,
|
||||
VolumeMute,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
volumeMuted: 0,
|
||||
}
|
||||
VolumeHighIcon,
|
||||
VolumeLowIcon,
|
||||
VolumeMediumIcon,
|
||||
VolumeMuteIcon,
|
||||
},
|
||||
data: () => ({
|
||||
volumeMuted: 0,
|
||||
}),
|
||||
computed: {
|
||||
player() {
|
||||
return this.$store.state.player
|
||||
},
|
||||
...mapState(usePlayer, ['volume']),
|
||||
},
|
||||
methods: {
|
||||
...mapActions(usePlayer, ['setVolume']),
|
||||
mute() {
|
||||
this.volumeMuted = this.player.volume
|
||||
this.$store.dispatch('player/volume', 0)
|
||||
},
|
||||
unmute() {
|
||||
this.$store.dispatch('player/volume', this.volumeMuted)
|
||||
this.volumeMuted = this.volume
|
||||
this.setVolume(0)
|
||||
},
|
||||
t,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
div {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
justify-content: center;
|
||||
}
|
||||
div {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
input {
|
||||
transform: rotate(270deg);
|
||||
width: 4rem;
|
||||
}
|
||||
input {
|
||||
transform: rotate(270deg);
|
||||
width: 4rem;
|
||||
}
|
||||
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
28
src/components/Settings/Export.vue
Normal file
28
src/components/Settings/Export.vue
Normal file
@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<NcAppNavigationItem
|
||||
:href="generateUrl('/apps/repod/opml/export')"
|
||||
:name="t('repod', 'Export subscriptions')">
|
||||
<template #icon>
|
||||
<ExportIcon :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import ExportIcon from 'vue-material-design-icons/Export.vue'
|
||||
import { NcAppNavigationItem } from '@nextcloud/vue'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
|
||||
export default {
|
||||
name: 'Export',
|
||||
components: {
|
||||
ExportIcon,
|
||||
NcAppNavigationItem,
|
||||
},
|
||||
methods: {
|
||||
generateUrl,
|
||||
t,
|
||||
},
|
||||
}
|
||||
</script>
|
73
src/components/Settings/Filters.vue
Normal file
73
src/components/Settings/Filters.vue
Normal file
@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<NcAppNavigationItem
|
||||
:allow-collapse="true"
|
||||
menu-placement="top"
|
||||
:name="t('repod', 'Filtering episodes')">
|
||||
<template #actions>
|
||||
<NcActionCheckbox
|
||||
:disabled="all"
|
||||
:model-value="all"
|
||||
@change="
|
||||
setFilters({
|
||||
listened: true,
|
||||
listening: true,
|
||||
unlistened: true,
|
||||
})
|
||||
">
|
||||
{{ t('repod', 'Show all') }}
|
||||
</NcActionCheckbox>
|
||||
<NcActionCheckbox
|
||||
:model-value="filters.listened"
|
||||
@change="setFilters({ listened: !filters.listened })">
|
||||
{{ t('repod', 'Listened') }}
|
||||
</NcActionCheckbox>
|
||||
<NcActionCheckbox
|
||||
:model-value="filters.listening"
|
||||
@change="setFilters({ listening: !filters.listening })">
|
||||
{{ t('repod', 'Listening') }}
|
||||
</NcActionCheckbox>
|
||||
<NcActionCheckbox
|
||||
:model-value="filters.unlistened"
|
||||
@change="setFilters({ unlistened: !filters.unlistened })">
|
||||
{{ t('repod', 'Unlistened') }}
|
||||
</NcActionCheckbox>
|
||||
</template>
|
||||
<template #icon>
|
||||
<FilterIcon v-if="all" :size="20" />
|
||||
<FilterSettingsIcon v-if="!all" :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { NcActionCheckbox, NcAppNavigationItem } from '@nextcloud/vue'
|
||||
import { mapActions, mapState } from 'pinia'
|
||||
import FilterIcon from 'vue-material-design-icons/Filter.vue'
|
||||
import FilterSettingsIcon from 'vue-material-design-icons/FilterSettings.vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { useSettings } from '../../store/settings.ts'
|
||||
|
||||
export default {
|
||||
name: 'Filters',
|
||||
components: {
|
||||
FilterIcon,
|
||||
FilterSettingsIcon,
|
||||
NcAppNavigationItem,
|
||||
NcActionCheckbox,
|
||||
},
|
||||
computed: {
|
||||
...mapState(useSettings, ['filters']),
|
||||
all() {
|
||||
return (
|
||||
this.filters.listened &&
|
||||
this.filters.listening &&
|
||||
this.filters.unlistened
|
||||
)
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useSettings, ['setFilters']),
|
||||
t,
|
||||
},
|
||||
}
|
||||
</script>
|
78
src/components/Settings/Import.vue
Normal file
78
src/components/Settings/Import.vue
Normal file
@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<NcAppNavigationItem
|
||||
:name="t('repod', 'Import subscriptions')"
|
||||
@click="modal = true">
|
||||
<template #extra>
|
||||
<NcModal v-if="modal" @close="modal = false">
|
||||
<div class="modal">
|
||||
<h2>{{ t('repod', 'Import OPML file') }}</h2>
|
||||
<form
|
||||
v-if="!loading"
|
||||
:action="generateUrl('/apps/repod/opml/import')"
|
||||
enctype="multipart/form-data"
|
||||
method="post"
|
||||
@submit.prevent="importOpml">
|
||||
<input
|
||||
accept="application/xml,.opml"
|
||||
name="import"
|
||||
required
|
||||
type="file" />
|
||||
<input type="submit" />
|
||||
</form>
|
||||
<Loading v-if="loading" />
|
||||
</div>
|
||||
</NcModal>
|
||||
</template>
|
||||
<template #icon>
|
||||
<ImportIcon :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { NcAppNavigationItem, NcModal } from '@nextcloud/vue'
|
||||
import ImportIcon from 'vue-material-design-icons/Import.vue'
|
||||
import Loading from '../Atoms/Loading.vue'
|
||||
import axios from '@nextcloud/axios'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
|
||||
export default {
|
||||
name: 'Import',
|
||||
components: {
|
||||
ImportIcon,
|
||||
Loading,
|
||||
NcAppNavigationItem,
|
||||
NcModal,
|
||||
},
|
||||
data: () => ({
|
||||
loading: false,
|
||||
modal: false,
|
||||
}),
|
||||
methods: {
|
||||
generateUrl,
|
||||
t,
|
||||
async importOpml(event: Event) {
|
||||
try {
|
||||
const target = event.target as HTMLFormElement
|
||||
const formData = new FormData(target)
|
||||
this.loading = true
|
||||
await axios.post(target.action, formData)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
location.reload()
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 2rem;
|
||||
}
|
||||
</style>
|
26
src/components/Settings/Rate.vue
Normal file
26
src/components/Settings/Rate.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<NcAppNavigationItem
|
||||
href="https://apps.nextcloud.com/apps/repod#comments"
|
||||
:name="t('repod', 'Rate RePod ❤️')">
|
||||
<template #icon>
|
||||
<StarIcon :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { NcAppNavigationItem } from '@nextcloud/vue'
|
||||
import StarIcon from 'vue-material-design-icons/Star.vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
|
||||
export default {
|
||||
name: 'Rate',
|
||||
components: {
|
||||
NcAppNavigationItem,
|
||||
StarIcon,
|
||||
},
|
||||
methods: {
|
||||
t,
|
||||
},
|
||||
}
|
||||
</script>
|
30
src/components/Settings/Settings.vue
Normal file
30
src/components/Settings/Settings.vue
Normal file
@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<NcAppNavigationSettings>
|
||||
<Filters />
|
||||
<Speed />
|
||||
<Import />
|
||||
<Export />
|
||||
<Rate />
|
||||
</NcAppNavigationSettings>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Export from './Export.vue'
|
||||
import Filters from './Filters.vue'
|
||||
import Import from './Import.vue'
|
||||
import { NcAppNavigationSettings } from '@nextcloud/vue'
|
||||
import Rate from './Rate.vue'
|
||||
import Speed from './Speed.vue'
|
||||
|
||||
export default {
|
||||
name: 'Settings',
|
||||
components: {
|
||||
Export,
|
||||
Filters,
|
||||
Import,
|
||||
NcAppNavigationSettings,
|
||||
Rate,
|
||||
Speed,
|
||||
},
|
||||
}
|
||||
</script>
|
68
src/components/Settings/Speed.vue
Normal file
68
src/components/Settings/Speed.vue
Normal file
@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<NcAppNavigationItem :name="t('repod', 'Playback speed')">
|
||||
<template #extra>
|
||||
<div class="extra">
|
||||
<MinusIcon class="pointer" :size="20" @click="changeRate(-0.1)" />
|
||||
<NcCounterBubble class="counter">x{{ rate }}</NcCounterBubble>
|
||||
<PlusIcon class="pointer" :size="20" @click="changeRate(0.1)" />
|
||||
</div>
|
||||
</template>
|
||||
<template #icon>
|
||||
<SpeedometerSlowIcon v-if="rate < 1" :size="20" />
|
||||
<SpeedometerMediumIcon v-if="rate === 1" :size="20" />
|
||||
<SpeedometerIcon v-if="rate > 1" :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { NcAppNavigationItem, NcCounterBubble } from '@nextcloud/vue'
|
||||
import { mapActions, mapState } from 'pinia'
|
||||
import MinusIcon from 'vue-material-design-icons/Minus.vue'
|
||||
import PlusIcon from 'vue-material-design-icons/Plus.vue'
|
||||
import SpeedometerIcon from 'vue-material-design-icons/Speedometer.vue'
|
||||
import SpeedometerMediumIcon from 'vue-material-design-icons/SpeedometerMedium.vue'
|
||||
import SpeedometerSlowIcon from 'vue-material-design-icons/SpeedometerSlow.vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { usePlayer } from '../../store/player.ts'
|
||||
|
||||
export default {
|
||||
name: 'Speed',
|
||||
components: {
|
||||
NcAppNavigationItem,
|
||||
NcCounterBubble,
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
SpeedometerIcon,
|
||||
SpeedometerMediumIcon,
|
||||
SpeedometerSlowIcon,
|
||||
},
|
||||
computed: {
|
||||
...mapState(usePlayer, ['rate']),
|
||||
},
|
||||
methods: {
|
||||
...mapActions(usePlayer, ['setRate']),
|
||||
t,
|
||||
changeRate(diff: number) {
|
||||
const newRate = parseFloat((this.rate + diff).toPrecision(2))
|
||||
this.setRate(newRate > 0 ? newRate : this.rate)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.counter {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.extra {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
@ -1,86 +0,0 @@
|
||||
<template>
|
||||
<NcAppNavigationItem :loading="loading"
|
||||
:name="feed ? feed.title : url"
|
||||
:to="toUrl">
|
||||
<template #icon>
|
||||
<NcAvatar v-if="feed"
|
||||
:display-name="feed.author"
|
||||
:is-no-user="true"
|
||||
:url="feed.imageUrl" />
|
||||
<Alert v-if="failed" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<NcActionButton @click="deleteSubscription">
|
||||
<template #icon>
|
||||
<Delete :size="20" />
|
||||
</template>
|
||||
{{ t('repod', 'Delete') }}
|
||||
</NcActionButton>
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { NcActionButton, NcAppNavigationItem, NcAvatar } from '@nextcloud/vue'
|
||||
import Alert from 'vue-material-design-icons/Alert.vue'
|
||||
import Delete from 'vue-material-design-icons/Delete.vue'
|
||||
import axios from '@nextcloud/axios'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
|
||||
export default {
|
||||
name: 'Item',
|
||||
components: {
|
||||
Alert,
|
||||
Delete,
|
||||
NcActionButton,
|
||||
NcAppNavigationItem,
|
||||
NcAvatar,
|
||||
},
|
||||
props: {
|
||||
url: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
failed: false,
|
||||
loading: true,
|
||||
feed: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
toUrl() {
|
||||
return `/${btoa(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>
|
151
src/components/Sidebar/Subscription.vue
Normal file
151
src/components/Sidebar/Subscription.vue
Normal file
@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<NcAppNavigationItem
|
||||
:loading="loading"
|
||||
:name="feed?.data?.title || url"
|
||||
:to="toFeedUrl(url)">
|
||||
<template #actions>
|
||||
<NcActionButton
|
||||
:aria-label="t('repod', 'Favorite')"
|
||||
:model-value="feed?.isFavorite"
|
||||
:name="t('repod', 'Favorite')"
|
||||
:title="t('repod', 'Favorite')"
|
||||
@update:modelValue="switchFavorite($event)">
|
||||
<template #icon>
|
||||
<StarPlusIcon v-if="!feed?.isFavorite" :size="20" />
|
||||
<StarRemoveIcon v-if="feed?.isFavorite" :size="20" />
|
||||
</template>
|
||||
</NcActionButton>
|
||||
<NcActionButton
|
||||
:aria-label="t(`core`, 'Delete')"
|
||||
:name="t(`core`, 'Delete')"
|
||||
:title="t(`core`, 'Delete')"
|
||||
@click="deleteSubscription">
|
||||
<template #icon>
|
||||
<DeleteIcon :size="20" />
|
||||
</template>
|
||||
</NcActionButton>
|
||||
</template>
|
||||
<template #icon>
|
||||
<NcAvatar
|
||||
:display-name="feed?.data?.author || feed?.data?.title"
|
||||
:is-no-user="true"
|
||||
:title="feed?.data?.author"
|
||||
:url="feed?.data?.imageUrl" />
|
||||
<StarIcon v-if="feed?.isFavorite" class="star" :size="20" />
|
||||
<AlertIcon v-if="failed" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { NcActionButton, NcAppNavigationItem, NcAvatar } from '@nextcloud/vue'
|
||||
import { mapActions, mapState } from 'pinia'
|
||||
import AlertIcon from 'vue-material-design-icons/Alert.vue'
|
||||
import DeleteIcon from 'vue-material-design-icons/Delete.vue'
|
||||
import type { PersonalSettingsPodcastDataInterface } from '../../utils/types.ts'
|
||||
import StarIcon from 'vue-material-design-icons/Star.vue'
|
||||
import StarPlusIcon from 'vue-material-design-icons/StarPlus.vue'
|
||||
import StarRemoveIcon from 'vue-material-design-icons/StarRemove.vue'
|
||||
import axios from '@nextcloud/axios'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { showError } from '../../utils/toast.ts'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { toFeedUrl } from '../../utils/url.ts'
|
||||
import { useSubscriptions } from '../../store/subscriptions.ts'
|
||||
|
||||
export default {
|
||||
name: 'Subscription',
|
||||
components: {
|
||||
AlertIcon,
|
||||
DeleteIcon,
|
||||
NcActionButton,
|
||||
NcAppNavigationItem,
|
||||
NcAvatar,
|
||||
StarIcon,
|
||||
StarPlusIcon,
|
||||
StarRemoveIcon,
|
||||
},
|
||||
props: {
|
||||
url: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
failed: false,
|
||||
loading: true,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState(useSubscriptions, ['getSubByUrl', 'subs']),
|
||||
feed() {
|
||||
return this.getSubByUrl(this.url)
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
try {
|
||||
const podcastData =
|
||||
await axios.get<PersonalSettingsPodcastDataInterface>(
|
||||
generateUrl(
|
||||
'/apps/gpoddersync/personal_settings/podcast_data?url={url}',
|
||||
{
|
||||
url: this.url,
|
||||
},
|
||||
),
|
||||
)
|
||||
this.addMetadatas(this.url, podcastData.data.data)
|
||||
} catch (e) {
|
||||
this.failed = true
|
||||
console.error(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useSubscriptions, ['fetch', 'addMetadatas', 'setFavorite']),
|
||||
t,
|
||||
toFeedUrl,
|
||||
async deleteSubscription() {
|
||||
if (
|
||||
confirm(
|
||||
t('repod', 'Are you sure you want to delete this subscription?'),
|
||||
)
|
||||
) {
|
||||
try {
|
||||
this.loading = true
|
||||
await axios.post(
|
||||
generateUrl('/apps/gpoddersync/subscription_change/create'),
|
||||
{ add: [], remove: [this.url] },
|
||||
)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
showError(t('repod', 'Error while removing the feed'))
|
||||
} finally {
|
||||
this.setFavorite(this.url, false)
|
||||
this.loading = false
|
||||
this.fetch()
|
||||
}
|
||||
}
|
||||
},
|
||||
switchFavorite(value: boolean) {
|
||||
if (value) {
|
||||
if (this.subs.filter((sub) => sub.isFavorite).length >= 10) {
|
||||
showError(t('repod', 'You can only have 10 favorites'))
|
||||
return
|
||||
}
|
||||
}
|
||||
this.setFavorite(this.url, value)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.star {
|
||||
bottom: 2px;
|
||||
color: yellow;
|
||||
left: 22px;
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
@ -1,58 +1,70 @@
|
||||
<template>
|
||||
<NcAppNavigation>
|
||||
<NcAppContentList>
|
||||
<router-link to="/">
|
||||
<NcAppNavigationNew :text="t('repod', 'Add a podcast')">
|
||||
<template #icon>
|
||||
<Plus :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationNew>
|
||||
</router-link>
|
||||
<Loading v-if="loading" />
|
||||
<AdaptativeList v-if="!loading">
|
||||
<Item v-for="subscriptionUrl of subscriptions"
|
||||
:key="subscriptionUrl"
|
||||
:url="subscriptionUrl" />
|
||||
</AdaptativeList>
|
||||
</NcAppContentList>
|
||||
</NcAppNavigation>
|
||||
<AppNavigation>
|
||||
<template #list>
|
||||
<NcAppContentList>
|
||||
<router-link to="/discover">
|
||||
<NcAppNavigationNew :text="t('repod', 'Add a podcast')">
|
||||
<template #icon>
|
||||
<PlusIcon :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationNew>
|
||||
</router-link>
|
||||
<Loading v-if="loading" />
|
||||
<NcAppNavigationList v-if="!loading">
|
||||
<Subscription
|
||||
v-for="sub of subs.filter((sub) => sub.isFavorite)"
|
||||
:key="sub.metrics.url"
|
||||
:url="sub.metrics.url" />
|
||||
<Subscription
|
||||
v-for="sub of subs.filter((sub) => !sub.isFavorite)"
|
||||
:key="sub.metrics.url"
|
||||
:url="sub.metrics.url" />
|
||||
</NcAppNavigationList>
|
||||
</NcAppContentList>
|
||||
</template>
|
||||
<template #footer>
|
||||
<Settings />
|
||||
</template>
|
||||
</AppNavigation>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { NcAppContentList, NcAppNavigation, NcAppNavigationNew } from '@nextcloud/vue'
|
||||
import AdaptativeList from '../Atoms/AdaptativeList.vue'
|
||||
import Item from './Item.vue'
|
||||
<script lang="ts">
|
||||
import {
|
||||
NcAppContentList,
|
||||
NcAppNavigationList,
|
||||
NcAppNavigationNew,
|
||||
} from '@nextcloud/vue'
|
||||
import { mapActions, mapState } from 'pinia'
|
||||
import AppNavigation from '../Atoms/AppNavigation.vue'
|
||||
import Loading from '../Atoms/Loading.vue'
|
||||
import Plus from 'vue-material-design-icons/Plus.vue'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
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'
|
||||
|
||||
export default {
|
||||
name: 'Subscriptions',
|
||||
components: {
|
||||
AdaptativeList,
|
||||
Item,
|
||||
AppNavigation,
|
||||
Loading,
|
||||
NcAppContentList,
|
||||
NcAppNavigation,
|
||||
NcAppNavigationList,
|
||||
NcAppNavigationNew,
|
||||
Plus,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
}
|
||||
PlusIcon,
|
||||
Settings,
|
||||
Subscription,
|
||||
},
|
||||
data: () => ({
|
||||
loading: true,
|
||||
}),
|
||||
computed: {
|
||||
currentEpisode() {
|
||||
return this.$store.state.player.episode
|
||||
},
|
||||
subscriptions() {
|
||||
return this.$store.state.subscriptions.subscriptions
|
||||
},
|
||||
...mapState(useSubscriptions, ['subs']),
|
||||
},
|
||||
async mounted() {
|
||||
try {
|
||||
await this.$store.dispatch('subscriptions/fetch')
|
||||
await this.fetch()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
showError(t('repod', 'Could not fetch subscriptions'))
|
||||
@ -60,5 +72,9 @@ export default {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useSubscriptions, ['fetch']),
|
||||
t,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
18
src/main.js
18
src/main.js
@ -1,18 +0,0 @@
|
||||
import { translatePlural as n, translate as t } from '@nextcloud/l10n'
|
||||
import App from './App.vue'
|
||||
import Vue from 'vue'
|
||||
import { generateFilePath } from '@nextcloud/router'
|
||||
import router from './router.js'
|
||||
import store from './store/main.js'
|
||||
|
||||
// eslint-disable-next-line
|
||||
__webpack_public_path__ = generateFilePath(appName, '', 'js/')
|
||||
|
||||
Vue.mixin({ methods: { t, n } })
|
||||
|
||||
export default new Vue({
|
||||
el: '#content',
|
||||
router,
|
||||
store,
|
||||
render: h => h(App),
|
||||
})
|
12
src/main.ts
Normal file
12
src/main.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import '@formatjs/intl-segmenter/polyfill'
|
||||
import App from './App.vue'
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import router from './router.ts'
|
||||
|
||||
const Vue = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
Vue.use(pinia)
|
||||
Vue.use(router)
|
||||
Vue.mount('#content')
|
@ -1,23 +0,0 @@
|
||||
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
|
25
src/router.ts
Normal file
25
src/router.ts
Normal file
@ -0,0 +1,25 @@
|
||||
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
|
@ -1,15 +0,0 @@
|
||||
import Vuex, { Store } from 'vuex'
|
||||
import Vue from 'vue'
|
||||
import { player } from './player.js'
|
||||
import { subscriptions } from './subscriptions.js'
|
||||
|
||||
Vue.use(Vuex)
|
||||
|
||||
const store = new Store({
|
||||
modules: {
|
||||
player,
|
||||
subscriptions,
|
||||
},
|
||||
})
|
||||
|
||||
export default store
|
@ -1,105 +0,0 @@
|
||||
import axios from '@nextcloud/axios'
|
||||
import { format } from 'date-fns'
|
||||
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.commit('player/paused', false)
|
||||
audio.onpause = () => store.commit('player/paused', true)
|
||||
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,
|
||||
},
|
||||
mutations: {
|
||||
action: (state, action) => {
|
||||
state.episode.action = action
|
||||
|
||||
if (action && action.position && action.position < action.total) {
|
||||
audio.currentTime = action.position
|
||||
}
|
||||
},
|
||||
currentTime: (state, currentTime) => {
|
||||
state.currentTime = currentTime
|
||||
},
|
||||
duration: (state, duration) => {
|
||||
state.duration = duration
|
||||
},
|
||||
episode: (state, episode) => {
|
||||
state.episode = episode
|
||||
|
||||
if (episode) {
|
||||
state.podcastUrl = atob(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
|
||||
}
|
||||
} 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
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
load: async (context, episode) => {
|
||||
context.commit('episode', episode)
|
||||
try {
|
||||
const action = await axios.get(generateUrl('/apps/repod/episodes/action?url={url}', { url: episode.url }))
|
||||
context.commit('action', action.data)
|
||||
} catch {}
|
||||
},
|
||||
pause: (context) => {
|
||||
audio.pause()
|
||||
context.dispatch('time')
|
||||
},
|
||||
play: () => audio.play(),
|
||||
seek: (context, currentTime) => {
|
||||
audio.currentTime = currentTime
|
||||
context.dispatch('time')
|
||||
},
|
||||
stop: (context) => {
|
||||
context.dispatch('pause')
|
||||
context.commit('episode', null)
|
||||
},
|
||||
time: async (context) => axios.post(generateUrl('/apps/gpoddersync/episode_action/create'), [{
|
||||
podcast: context.state.podcastUrl,
|
||||
episode: context.state.episode.url,
|
||||
guid: context.state.episode.guid,
|
||||
action: 'play',
|
||||
timestamp: format(new Date(), 'yyyy-MM-dd\'T\'HH:mm:ss'),
|
||||
started: Math.round(context.state.action ? context.state.action.started : 0),
|
||||
position: Math.round(audio.currentTime),
|
||||
total: Math.round(audio.duration),
|
||||
}]),
|
||||
volume: (_, volume) => {
|
||||
audio.volume = volume
|
||||
},
|
||||
},
|
||||
}
|
137
src/store/player.ts
Normal file
137
src/store/player.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import type { EpisodeActionInterface, EpisodeInterface } from '../utils/types.ts'
|
||||
import { getCookie, setCookie } from '../utils/cookies.ts'
|
||||
import axios from '@nextcloud/axios'
|
||||
import { defineStore } from 'pinia'
|
||||
import { formatEpisodeTimestamp } from '../utils/time.ts'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { showError } from '../utils/toast.ts'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
|
||||
const audio = new Audio()
|
||||
|
||||
export const usePlayer = defineStore('player', {
|
||||
state: () => ({
|
||||
currentTime: null as number | null,
|
||||
duration: null as number | null,
|
||||
episode: null as EpisodeInterface | null,
|
||||
loaded: false,
|
||||
paused: true,
|
||||
playCount: 0,
|
||||
podcastUrl: null as string | null,
|
||||
volume: 1,
|
||||
rate: 1,
|
||||
started: 0,
|
||||
}),
|
||||
actions: {
|
||||
init() {
|
||||
audio.playbackRate = parseFloat(getCookie('repod.rate') || '1')
|
||||
audio.volume = parseFloat(getCookie('repod.volume') || '1')
|
||||
|
||||
audio.ondurationchange = () => (this.duration = audio.duration)
|
||||
audio.onended = () => this.stop()
|
||||
audio.onloadeddata = () => (this.loaded = true)
|
||||
audio.onpause = () => this.pause()
|
||||
audio.onplay = () => this.play()
|
||||
audio.onratechange = () => (this.rate = audio.playbackRate)
|
||||
audio.onseeked = () => (this.currentTime = audio.currentTime)
|
||||
audio.ontimeupdate = () => (this.currentTime = audio.currentTime)
|
||||
audio.onvolumechange = () => (this.volume = audio.volume)
|
||||
|
||||
setInterval(this.act, 40000)
|
||||
setInterval(this.conflict, 1000)
|
||||
},
|
||||
act() {
|
||||
if (this.paused === false) {
|
||||
this.time()
|
||||
}
|
||||
},
|
||||
conflict() {
|
||||
this.playCount = 0
|
||||
},
|
||||
async load(episode: EpisodeInterface | null, podcastUrl?: string) {
|
||||
this.episode = episode
|
||||
this.podcastUrl = podcastUrl || null
|
||||
|
||||
if (this.episode?.url) {
|
||||
audio.src = this.episode.url
|
||||
audio.load()
|
||||
|
||||
try {
|
||||
const action = await axios.get<EpisodeActionInterface>(
|
||||
generateUrl('/apps/repod/episodes/action?url={url}', {
|
||||
url: this.episode.url,
|
||||
}),
|
||||
)
|
||||
|
||||
this.episode.action = action.data
|
||||
} catch {}
|
||||
|
||||
if (
|
||||
this.episode.action &&
|
||||
this.episode.action.position < this.episode.action.total
|
||||
) {
|
||||
audio.currentTime = this.episode.action.position
|
||||
this.started = audio.currentTime
|
||||
}
|
||||
|
||||
audio.play()
|
||||
} else {
|
||||
this.loaded = false
|
||||
this.podcastUrl = null
|
||||
audio.src = ''
|
||||
}
|
||||
},
|
||||
pause() {
|
||||
audio.pause()
|
||||
this.paused = true
|
||||
this.time()
|
||||
},
|
||||
play() {
|
||||
this.playCount++
|
||||
|
||||
if (this.playCount > 10) {
|
||||
showError(t('repod', 'A browser extension conflict with RePod'))
|
||||
} else {
|
||||
audio.play()
|
||||
this.paused = false
|
||||
this.started = audio.currentTime
|
||||
}
|
||||
},
|
||||
seek(currentTime: number) {
|
||||
audio.currentTime = currentTime
|
||||
this.time()
|
||||
},
|
||||
stop() {
|
||||
this.pause()
|
||||
this.episode = null
|
||||
},
|
||||
time() {
|
||||
if (!this.podcastUrl || !this.episode?.url) {
|
||||
return
|
||||
}
|
||||
|
||||
this.episode.action = {
|
||||
podcast: this.podcastUrl,
|
||||
episode: this.episode.url,
|
||||
guid: this.episode.guid,
|
||||
action: 'play',
|
||||
timestamp: formatEpisodeTimestamp(new Date()),
|
||||
started: Math.round(this.started),
|
||||
position: Math.round(audio.currentTime),
|
||||
total: Math.round(audio.duration),
|
||||
}
|
||||
|
||||
axios.post(generateUrl('/apps/gpoddersync/episode_action/create'), [
|
||||
this.episode.action,
|
||||
])
|
||||
},
|
||||
setVolume(volume: number) {
|
||||
audio.volume = volume
|
||||
setCookie('repod.volume', volume.toString(), 365)
|
||||
},
|
||||
setRate(rate: number) {
|
||||
audio.playbackRate = rate
|
||||
setCookie('repod.rate', rate.toString(), 365)
|
||||
},
|
||||
},
|
||||
})
|
37
src/store/settings.ts
Normal file
37
src/store/settings.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { getCookie, setCookie } from '../utils/cookies.ts'
|
||||
import type { FiltersInterface } from '../utils/types.ts'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useSettings = defineStore('settings', {
|
||||
state: () => {
|
||||
try {
|
||||
const filters = JSON.parse(getCookie('repod.filters') || '{}') || {}
|
||||
|
||||
if (!filters.length) {
|
||||
throw new Error('Empty cookie')
|
||||
}
|
||||
|
||||
return {
|
||||
filters: {
|
||||
listened: filters.listened,
|
||||
listening: filters.listening,
|
||||
unlistened: filters.unlistened,
|
||||
},
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
filters: {
|
||||
listened: true,
|
||||
listening: true,
|
||||
unlistened: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
setFilters(filters: Partial<FiltersInterface>) {
|
||||
this.filters = { ...this.filters, ...filters }
|
||||
setCookie('repod.filters', JSON.stringify(this.filters), 365)
|
||||
},
|
||||
},
|
||||
})
|
@ -1,20 +0,0 @@
|
||||
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'))
|
||||
context.commit('set', metrics.data.subscriptions.map(sub => sub.url))
|
||||
},
|
||||
},
|
||||
}
|
59
src/store/subscriptions.ts
Normal file
59
src/store/subscriptions.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import type {
|
||||
PersonalSettingsMetricsInterface,
|
||||
PodcastDataInterface,
|
||||
SubscriptionInterface,
|
||||
} from '../utils/types.ts'
|
||||
import { getCookie, setCookie } from '../utils/cookies.ts'
|
||||
import axios from '@nextcloud/axios'
|
||||
import { defineStore } from 'pinia'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
|
||||
export const useSubscriptions = defineStore('subscriptions', {
|
||||
state: () => ({
|
||||
subs: [] as SubscriptionInterface[],
|
||||
}),
|
||||
getters: {
|
||||
getSubByUrl: (state) => (url: string) =>
|
||||
state.subs.find((sub) => sub.metrics.url === url),
|
||||
},
|
||||
actions: {
|
||||
async fetch() {
|
||||
let favorites: string[] = []
|
||||
try {
|
||||
favorites = JSON.parse(getCookie('repod.favorites') || '[]') || []
|
||||
} catch {}
|
||||
|
||||
const metrics = await axios.get<PersonalSettingsMetricsInterface>(
|
||||
generateUrl('/apps/gpoddersync/personal_settings/metrics'),
|
||||
)
|
||||
|
||||
this.subs = [...metrics.data.subscriptions]
|
||||
.sort((a, b) => b.listenedSeconds - a.listenedSeconds)
|
||||
.map((sub) => ({
|
||||
metrics: sub,
|
||||
isFavorite: favorites.includes(sub.url),
|
||||
data: this.getSubByUrl(sub.url)?.data,
|
||||
}))
|
||||
},
|
||||
addMetadatas(link: string, data: PodcastDataInterface) {
|
||||
this.subs = this.subs.map((sub) =>
|
||||
sub.metrics.url === link ? { ...sub, data } : sub,
|
||||
)
|
||||
},
|
||||
setFavorite(link: string, isFavorite: boolean) {
|
||||
this.subs = this.subs.map((sub) =>
|
||||
sub.metrics.url === link ? { ...sub, isFavorite } : sub,
|
||||
)
|
||||
|
||||
setCookie(
|
||||
'repod.favorites',
|
||||
JSON.stringify(
|
||||
this.subs
|
||||
.filter((sub) => sub.isFavorite)
|
||||
.map((sub) => sub.metrics.url),
|
||||
),
|
||||
365,
|
||||
)
|
||||
},
|
||||
},
|
||||
})
|
26
src/utils/cookies.ts
Normal file
26
src/utils/cookies.ts
Normal file
@ -0,0 +1,26 @@
|
||||
// https://grafikart.fr/tutoriels/javascript-cookies-2079
|
||||
/**
|
||||
* Récupère les données associées à un cookie
|
||||
* @param {string} name Nom du cookie à récupérer
|
||||
* @return {string|null}
|
||||
*/
|
||||
export const getCookie = (name: string): string | null => {
|
||||
const cookies = document.cookie.split('; ')
|
||||
const value = cookies.find((c) => c.startsWith(name + '='))?.split('=')[1]
|
||||
if (value === undefined) {
|
||||
return null
|
||||
}
|
||||
return decodeURIComponent(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Créer ou modifie la valeur d'un cookie avec une durée spécifique
|
||||
* @param {string} name Nom du cookie
|
||||
* @param {string} value Value du cookie
|
||||
* @param {number} days Durée de vie du cookie (en jours)
|
||||
*/
|
||||
export const setCookie = (name: string, value: string, days: number) => {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() + days)
|
||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${date.toUTCString()}; SameSite=Strict;`
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
// 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.ts
Normal file
9
src/utils/size.ts
Normal file
@ -0,0 +1,9 @@
|
||||
// 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]
|
||||
)
|
||||
}
|
16
src/utils/status.ts
Normal file
16
src/utils/status.ts
Normal file
@ -0,0 +1,16 @@
|
||||
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)
|
@ -1,11 +0,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()) {
|
||||
timer = `${date.getUTCHours()}:${timer}`
|
||||
}
|
||||
|
||||
return timer
|
||||
}
|
53
src/utils/time.ts
Normal file
53
src/utils/time.ts
Normal file
@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Format a date to a timer
|
||||
* @param {Date} date The date
|
||||
* @return {string}
|
||||
*/
|
||||
export const formatTimer = (date: Date): string => {
|
||||
const minutes = date.getUTCMinutes().toString().padStart(2, '0')
|
||||
const seconds = date.getUTCSeconds().toString().padStart(2, '0')
|
||||
let timer = `${minutes}:${seconds}`
|
||||
|
||||
if (date.getUTCHours()) {
|
||||
timer = `${date.getUTCHours()}:${timer}`
|
||||
}
|
||||
|
||||
return timer
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date to a usefull timestamp string for the gPodder API
|
||||
* @param {Date} date The date
|
||||
* @return {string}
|
||||
*/
|
||||
export const formatEpisodeTimestamp = (date: Date): string => {
|
||||
const year = date.getFullYear()
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const day = date.getDate().toString().padStart(2, '0')
|
||||
const hours = date.getHours().toString().padStart(2, '0')
|
||||
const mins = date.getMinutes().toString().padStart(2, '0')
|
||||
const secs = date.getSeconds().toString().padStart(2, '0')
|
||||
|
||||
return `${year}-${month}-${day}T${hours}:${mins}:${secs}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date to a localized date string
|
||||
* @param {Date} date The date
|
||||
* @return {string}
|
||||
*/
|
||||
export const formatLocaleDate = (date: Date): string =>
|
||||
date.toLocaleDateString(undefined, { dateStyle: 'medium' })
|
||||
|
||||
/**
|
||||
* Returns the number of seconds from a duration feed's entry
|
||||
* @param {string} duration The duration feed's entry
|
||||
* @return {number}
|
||||
*/
|
||||
export const durationToSeconds = (duration: string): number => {
|
||||
const splitDuration = duration.split(':').reverse()
|
||||
let seconds = parseInt(splitDuration[0])
|
||||
seconds += splitDuration.length > 1 ? parseInt(splitDuration[1]) * 60 : 0
|
||||
seconds += splitDuration.length > 2 ? parseInt(splitDuration[2]) * 60 * 60 : 0
|
||||
return seconds
|
||||
}
|
14
src/utils/toast.ts
Normal file
14
src/utils/toast.ts
Normal file
@ -0,0 +1,14 @@
|
||||
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)')
|
73
src/utils/types.ts
Normal file
73
src/utils/types.ts
Normal file
@ -0,0 +1,73 @@
|
||||
export interface EpisodeActionInterface {
|
||||
podcast: string
|
||||
episode: string
|
||||
action: string
|
||||
timestamp: string
|
||||
started: number
|
||||
position: number
|
||||
total: number
|
||||
guid?: string
|
||||
id?: number
|
||||
}
|
||||
|
||||
export interface EpisodeInterface {
|
||||
title: string
|
||||
url: string
|
||||
name: string
|
||||
link?: string
|
||||
image?: string
|
||||
description?: string
|
||||
fetchedAtUnix: number
|
||||
guid: string
|
||||
type?: string
|
||||
size?: number
|
||||
pubDate?: {
|
||||
date: string
|
||||
timezone_type: number
|
||||
timezone: string
|
||||
}
|
||||
duration?: string
|
||||
action?: EpisodeActionInterface
|
||||
}
|
||||
|
||||
export interface FiltersInterface {
|
||||
listened: boolean
|
||||
listening: boolean
|
||||
unlistened: boolean
|
||||
}
|
||||
|
||||
export interface PodcastDataInterface {
|
||||
title: string
|
||||
author?: string
|
||||
link: string
|
||||
description?: string
|
||||
imageUrl?: string
|
||||
fetchedAtUnix: number
|
||||
imageBlob?: string | null
|
||||
}
|
||||
|
||||
export interface PodcastMetricsInterface {
|
||||
url: string
|
||||
listenedSeconds: number
|
||||
actionCounts: {
|
||||
delete: number
|
||||
download: number
|
||||
flattr: number
|
||||
new: number
|
||||
play: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface SubscriptionInterface {
|
||||
data?: PodcastDataInterface
|
||||
isFavorite?: boolean
|
||||
metrics: PodcastMetricsInterface
|
||||
}
|
||||
|
||||
export interface PersonalSettingsMetricsInterface {
|
||||
subscriptions: PodcastMetricsInterface[]
|
||||
}
|
||||
|
||||
export interface PersonalSettingsPodcastDataInterface {
|
||||
data: PodcastDataInterface
|
||||
}
|
5
src/utils/url.ts
Normal file
5
src/utils/url.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export const encodeUrl = (url: string) => encodeURIComponent(btoa(url))
|
||||
export const decodeUrl = (url: string) => atob(decodeURIComponent(url))
|
||||
export const toFeedUrl = (url: string) => `/feed/${encodeUrl(url)}`
|
||||
export const filenameFromUrl = (url: string) =>
|
||||
new URL(url).pathname.split('/').pop()
|
@ -1,42 +1,47 @@
|
||||
<template>
|
||||
<NcAppContent class="main">
|
||||
<NcTextField :label="t('repod', 'Find a podcast')"
|
||||
:value.sync="search">
|
||||
<Magnify :size="20" />
|
||||
<AppContent class="padding">
|
||||
<NcTextField v-model="search" :label="t('repod', 'Find a podcast')">
|
||||
<template #icon>
|
||||
<Magnify :size="20" />
|
||||
</template>
|
||||
</NcTextField>
|
||||
<Search v-if="search" :value="search" />
|
||||
<TopList v-if="!search" />
|
||||
<Toplist v-if="!search" type="hot" />
|
||||
<Toplist v-if="!search" type="new" />
|
||||
<AddRss v-if="!search" />
|
||||
</NcAppContent>
|
||||
</AppContent>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { NcAppContent, NcTextField } from '@nextcloud/vue'
|
||||
<script lang="ts">
|
||||
import AddRss from '../components/Discover/AddRss.vue'
|
||||
import AppContent from '../components/Atoms/AppContent.vue'
|
||||
import Magnify from 'vue-material-design-icons/Magnify.vue'
|
||||
import { NcTextField } from '@nextcloud/vue'
|
||||
import Search from '../components/Discover/Search.vue'
|
||||
import TopList from '../components/Discover/TopList.vue'
|
||||
import Toplist from '../components/Discover/Toplist.vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
|
||||
export default {
|
||||
name: 'Discover',
|
||||
components: {
|
||||
AddRss,
|
||||
AppContent,
|
||||
Magnify,
|
||||
NcAppContent,
|
||||
NcTextField,
|
||||
Search,
|
||||
TopList,
|
||||
Toplist,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
search: '',
|
||||
}
|
||||
data: () => ({
|
||||
search: '',
|
||||
}),
|
||||
methods: {
|
||||
t,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
padding: 15px 51px;
|
||||
}
|
||||
.padding {
|
||||
padding: 15px 51px;
|
||||
}
|
||||
</style>
|
||||
|
@ -1,55 +1,55 @@
|
||||
<template>
|
||||
<NcAppContent>
|
||||
<AppContent>
|
||||
<Loading v-if="loading" />
|
||||
<NcEmptyContent v-if="failed" :name="t('repod', 'Error loading feed')">
|
||||
<EmptyContent v-if="failed" :name="t('repod', 'Error loading feed')">
|
||||
<template #icon>
|
||||
<Alert />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
<Banner v-if="feed"
|
||||
:author="feed.author"
|
||||
:description="feed.description"
|
||||
:image-url="feed.imageUrl"
|
||||
:link="feed.link"
|
||||
:title="feed.title" />
|
||||
</EmptyContent>
|
||||
<Banner v-if="feed" :feed="feed" />
|
||||
<Episodes v-if="feed" />
|
||||
</NcAppContent>
|
||||
</AppContent>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { NcAppContent, NcEmptyContent } from '@nextcloud/vue'
|
||||
<script lang="ts">
|
||||
import Alert from 'vue-material-design-icons/Alert.vue'
|
||||
import AppContent from '../components/Atoms/AppContent.vue'
|
||||
import Banner from '../components/Feed/Banner.vue'
|
||||
import EmptyContent from '../components/Atoms/EmptyContent.vue'
|
||||
import Episodes from '../components/Feed/Episodes.vue'
|
||||
import Loading from '../components/Atoms/Loading.vue'
|
||||
import type { PodcastDataInterface } from '../utils/types.ts'
|
||||
import axios from '@nextcloud/axios'
|
||||
import { decodeUrl } from '../utils/url.ts'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
|
||||
export default {
|
||||
name: 'Feed',
|
||||
components: {
|
||||
Alert,
|
||||
AppContent,
|
||||
Banner,
|
||||
EmptyContent,
|
||||
Episodes,
|
||||
Loading,
|
||||
NcAppContent,
|
||||
NcEmptyContent,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
failed: false,
|
||||
loading: true,
|
||||
feed: null,
|
||||
}
|
||||
},
|
||||
data: () => ({
|
||||
failed: false,
|
||||
loading: true,
|
||||
feed: null as PodcastDataInterface | null,
|
||||
}),
|
||||
computed: {
|
||||
url() {
|
||||
return atob(this.$route.params.url)
|
||||
return decodeUrl(this.$route.params.url as string)
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
try {
|
||||
const podcastData = await axios.get(generateUrl('/apps/repod/podcast?url={url}', { url: this.url }))
|
||||
this.loading = true
|
||||
const podcastData = await axios.get<PodcastDataInterface>(
|
||||
generateUrl('/apps/repod/podcast?url={url}', { url: this.url }),
|
||||
)
|
||||
this.feed = podcastData.data
|
||||
} catch (e) {
|
||||
this.failed = true
|
||||
@ -58,5 +58,8 @@ export default {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
t,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user