mirror of
https://github.com/FreeTubeApp/FreeTube.git
synced 2025-12-05 01:10:31 +00:00
Compare commits
236 Commits
dependabot
...
c3941f219c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3941f219c | ||
|
|
f87892c7bf | ||
|
|
905c308ff5 | ||
|
|
a3693ecb1e | ||
|
|
5de006bca5 | ||
|
|
52cb9b7f94 | ||
|
|
c4dd47bedd | ||
|
|
b12c3527c5 | ||
|
|
fb78cf5240 | ||
|
|
863eb9e760 | ||
|
|
654e24ec4c | ||
|
|
92cac18c39 | ||
|
|
8847b35e81 | ||
|
|
2354fb0a88 | ||
|
|
585d63f543 | ||
|
|
b59323dacc | ||
|
|
e71a824e6b | ||
|
|
663273ea02 | ||
|
|
1453e0bf8d | ||
|
|
605febdafa | ||
|
|
6f27f2429f | ||
|
|
36501ba563 | ||
|
|
66e9fb74d8 | ||
|
|
6db9c7352d | ||
|
|
5dcc7b5dde | ||
|
|
6452db219b | ||
|
|
6590a18c46 | ||
|
|
34f0f54faf | ||
|
|
9f5637215a | ||
|
|
deef6d8f53 | ||
|
|
13d4acf664 | ||
|
|
1137add185 | ||
|
|
64796c3e6a | ||
|
|
aa0b576388 | ||
|
|
d1362205d9 | ||
|
|
bf9c5cdaca | ||
|
|
602d8d41e8 | ||
|
|
8f89e39926 | ||
|
|
85c38edefb | ||
|
|
50c7d0f2e4 | ||
|
|
888c007573 | ||
|
|
d7ca9f658a | ||
|
|
636a50be46 | ||
|
|
a95276784b | ||
|
|
5dc1270787 | ||
|
|
facc91546a | ||
|
|
4a5d8fa453 | ||
|
|
7c28dbb0d4 | ||
|
|
105d9b7d0c | ||
|
|
5e599ea721 | ||
|
|
44f0e5ca92 | ||
|
|
4253ada1e5 | ||
|
|
c6722b8714 | ||
|
|
39bf9126bd | ||
|
|
5bf3a5a796 | ||
|
|
e303ff8b56 | ||
|
|
bcaf110d6d | ||
|
|
f076a552df | ||
|
|
2e9aeae057 | ||
|
|
bd792a1f2f | ||
|
|
035a1c4748 | ||
|
|
bf64152ff0 | ||
|
|
e1bae5dc27 | ||
|
|
31bc5c541b | ||
|
|
3fdbd43b8a | ||
|
|
b91a15277f | ||
|
|
35d9663d95 | ||
|
|
ec724382de | ||
|
|
e9c48fbb58 | ||
|
|
aa745d5ead | ||
|
|
ee2ff267d1 | ||
|
|
24593d425e | ||
|
|
92f79616d1 | ||
|
|
c092104596 | ||
|
|
ac0a8b0134 | ||
|
|
f1d4991ca6 | ||
|
|
c14e332051 | ||
|
|
e7280db2b9 | ||
|
|
bf340071ad | ||
|
|
ea71f45be5 | ||
|
|
549e3f918a | ||
|
|
3b379dbcb4 | ||
|
|
8695da836b | ||
|
|
a711a9730b | ||
|
|
cb14c8c4ce | ||
|
|
8be984c2fc | ||
|
|
fadaa2457b | ||
|
|
fde2ade504 | ||
|
|
5e243c68ec | ||
|
|
44850093cb | ||
|
|
05cb312818 | ||
|
|
9e832a1302 | ||
|
|
1bc8e8861e | ||
|
|
30bca40908 | ||
|
|
010aa50acd | ||
|
|
774e370981 | ||
|
|
2aef9ad424 | ||
|
|
7d24561df9 | ||
|
|
94693c9f00 | ||
|
|
ec473ab875 | ||
|
|
050611cae3 | ||
|
|
8e66ac2550 | ||
|
|
10829adc08 | ||
|
|
d50e3d4da4 | ||
|
|
1ed5cd584e | ||
|
|
a0bb74fedc | ||
|
|
4b1bb5d651 | ||
|
|
cf4f48b17f | ||
|
|
047c3e6d28 | ||
|
|
a5b7e77588 | ||
|
|
9895c8b563 | ||
|
|
a9c6df7256 | ||
|
|
60d5f61785 | ||
|
|
1f59f22231 | ||
|
|
c06738b1ae | ||
|
|
ae5deb9844 | ||
|
|
c687a1c9f3 | ||
|
|
17650fdf34 | ||
|
|
7352534ed0 | ||
|
|
0f3ae6e720 | ||
|
|
26d62ba8e2 | ||
|
|
b3c14bf2b4 | ||
|
|
80a0af668b | ||
|
|
676cf13159 | ||
|
|
075c111eb5 | ||
|
|
13c2c87122 | ||
|
|
0c1ffe2d38 | ||
|
|
fcb311b26d | ||
|
|
31290be062 | ||
|
|
eff85a5f06 | ||
|
|
d584ad350d | ||
|
|
b8599c655d | ||
|
|
d2b13d75d9 | ||
|
|
00be448e3f | ||
|
|
09c265e445 | ||
|
|
d9e3461cdc | ||
|
|
81cc935ecf | ||
|
|
a71a6b69ae | ||
|
|
5f2a951645 | ||
|
|
61ac1559c0 | ||
|
|
eff357772f | ||
|
|
655074f102 | ||
|
|
e0bdbb1fcf | ||
|
|
3abd8e6a27 | ||
|
|
b01f851d1c | ||
|
|
614da2880f | ||
|
|
f98d411fc5 | ||
|
|
f503b0b71c | ||
|
|
03c75f373e | ||
|
|
2bdce9b98a | ||
|
|
fa79120f0a | ||
|
|
5e4a93b730 | ||
|
|
f2f2e9325c | ||
|
|
621a9ad30a | ||
|
|
93eb7394b2 | ||
|
|
fff5cb89d0 | ||
|
|
c34d8e934d | ||
|
|
3b08213ab1 | ||
|
|
bde7acfd10 | ||
|
|
2b2869a3d4 | ||
|
|
6a736d4b5e | ||
|
|
ebae6b9875 | ||
|
|
d01c864c2f | ||
|
|
06a4aa4dc5 | ||
|
|
caf2db57fb | ||
|
|
6dd00feb16 | ||
|
|
0d36e7b89f | ||
|
|
e6b1c20f42 | ||
|
|
bf2f9b4fb3 | ||
|
|
4c72a2358d | ||
|
|
d829cc2b16 | ||
|
|
ad669b3678 | ||
|
|
a8061f0131 | ||
|
|
b1ea445456 | ||
|
|
500438debe | ||
|
|
796fc4eda0 | ||
|
|
9ede2a3019 | ||
|
|
7ea2d6fdb4 | ||
|
|
1ec73aa173 | ||
|
|
293ea3f47a | ||
|
|
3fa5ccd199 | ||
|
|
075f0c6df1 | ||
|
|
41830bf91d | ||
|
|
2ef1ff7a18 | ||
|
|
eacc5ceca4 | ||
|
|
bc4d8ce2fa | ||
|
|
adbc650df0 | ||
|
|
faaf92a967 | ||
|
|
76ceac9efa | ||
|
|
8ad9964fed | ||
|
|
d4733b1ae0 | ||
|
|
9d0577f13b | ||
|
|
543a597497 | ||
|
|
ba54bfc997 | ||
|
|
b0469c4f62 | ||
|
|
fb1be7d7b0 | ||
|
|
6e79f3d541 | ||
|
|
ac0388e808 | ||
|
|
74cb414be8 | ||
|
|
d51c8d3ac3 | ||
|
|
0954d8cda8 | ||
|
|
890ba6f1e6 | ||
|
|
53bd699e8d | ||
|
|
8fc7b357c6 | ||
|
|
78624fde9c | ||
|
|
8714cff13b | ||
|
|
46da1cf840 | ||
|
|
ade259b9bc | ||
|
|
a29031faec | ||
|
|
c718ab2a3f | ||
|
|
963f42825d | ||
|
|
8b63948bbb | ||
|
|
e6599cfb30 | ||
|
|
c5b0be28c4 | ||
|
|
1baa962201 | ||
|
|
960677aabc | ||
|
|
f0a49c6bd5 | ||
|
|
6628c3e4ba | ||
|
|
d170945d11 | ||
|
|
e46bdedb75 | ||
|
|
8799b4990a | ||
|
|
fd96c7ad6f | ||
|
|
03d44792a7 | ||
|
|
95ec469d72 | ||
|
|
3e395f9586 | ||
|
|
7d0b48c27c | ||
|
|
19600d7076 | ||
|
|
15d0e644e5 | ||
|
|
52660a8f2b | ||
|
|
279f3bd7b3 | ||
|
|
03e0c47a3b | ||
|
|
76522d2685 | ||
|
|
b47aaa6563 | ||
|
|
cab43cbbdb | ||
|
|
b024a16deb | ||
|
|
f151e17efb |
66
.github/workflows/build.yml
vendored
66
.github/workflows/build.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [22.x]
|
||||
node-version: [24.x]
|
||||
runtime:
|
||||
- linux-x64
|
||||
- linux-armv7l
|
||||
@@ -46,14 +46,14 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: 'Use faster D: drive for yarn cache on Windows'
|
||||
if: startsWith(matrix.os, 'windows')
|
||||
run: yarn config set cache-folder D:\ft_yarn_cache
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "yarn"
|
||||
@@ -121,91 +121,91 @@ jobs:
|
||||
rm -rf ./squashfs-root ./appimagetool.AppImage
|
||||
|
||||
- name: Upload Linux .zip x64 Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64')
|
||||
with:
|
||||
name: freetube-${{ steps.versionNumber.outputs.result }}-linux-x64-portable.zip
|
||||
path: build/freetube-${{ steps.versionNumber.outputs.result }}.zip
|
||||
|
||||
- name: Upload Linux .7z x64 Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64')
|
||||
with:
|
||||
name: freetube-${{ steps.versionNumber.outputs.result }}-linux-x64-portable.7z
|
||||
path: build/freetube-${{ steps.versionNumber.outputs.result }}.7z
|
||||
|
||||
- name: Upload Linux .zip ARMv7l Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l')
|
||||
with:
|
||||
name: freetube-${{ steps.versionNumber.outputs.result }}-linux-armv7l-portable.zip
|
||||
path: build/freetube-${{ steps.versionNumber.outputs.result }}-armv7l.zip
|
||||
|
||||
- name: Upload Linux .7z ARMv7l Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l')
|
||||
with:
|
||||
name: freetube-${{ steps.versionNumber.outputs.result }}-linux-armv7l-portable.7z
|
||||
path: build/freetube-${{ steps.versionNumber.outputs.result }}-armv7l.7z
|
||||
|
||||
- name: Upload Linux .zip ARM64 Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64')
|
||||
with:
|
||||
name: freetube-${{ steps.versionNumber.outputs.result }}-linux-arm64-portable.zip
|
||||
path: build/freetube-${{ steps.versionNumber.outputs.result }}-arm64.zip
|
||||
|
||||
- name: Upload Linux .7z ARM64 Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64')
|
||||
with:
|
||||
name: freetube-${{ steps.versionNumber.outputs.result }}-linux-arm64-portable.7z
|
||||
path: build/freetube-${{ steps.versionNumber.outputs.result }}-arm64.7z
|
||||
|
||||
- name: Upload .deb x64 Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64')
|
||||
with:
|
||||
name: freetube_${{ steps.versionNumber.outputs.result }}_amd64.deb
|
||||
path: build/freetube_${{ steps.versionNumber.outputs.result }}_amd64.deb
|
||||
|
||||
- name: Upload .deb ARMv7l Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l')
|
||||
with:
|
||||
name: freetube_${{ steps.versionNumber.outputs.result }}_armv7l.deb
|
||||
path: build/freetube_${{ steps.versionNumber.outputs.result }}_armv7l.deb
|
||||
|
||||
- name: Upload .deb ARM64 Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64')
|
||||
with:
|
||||
name: freetube_${{ steps.versionNumber.outputs.result }}_arm64.deb
|
||||
path: build/freetube_${{ steps.versionNumber.outputs.result }}_arm64.deb
|
||||
|
||||
- name: Upload AppImage x64 Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64')
|
||||
with:
|
||||
name: freetube-${{ steps.versionNumber.outputs.result }}-amd64.AppImage
|
||||
path: build/FreeTube-${{ steps.versionNumber.outputs.result }}.AppImage
|
||||
|
||||
- name: Upload AppImage ARMv7l Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l')
|
||||
with:
|
||||
name: freetube-${{ steps.versionNumber.outputs.result }}-armv7l.AppImage
|
||||
path: build/FreeTube-${{ steps.versionNumber.outputs.result }}-armv7l.AppImage
|
||||
|
||||
- name: Upload AppImage ARM64 Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64')
|
||||
with:
|
||||
name: freetube-${{ steps.versionNumber.outputs.result }}-arm64.AppImage
|
||||
path: build/FreeTube-${{ steps.versionNumber.outputs.result }}-arm64.AppImage
|
||||
|
||||
- name: Upload .rpm x64 Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64')
|
||||
with:
|
||||
name: freetube-${{ steps.versionNumber.outputs.result }}.amd64.rpm
|
||||
@@ -214,119 +214,119 @@ jobs:
|
||||
# rpm are not built for armv7l
|
||||
|
||||
- name: Upload .rpm ARM64 Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64')
|
||||
with:
|
||||
name: freetube-${{ steps.versionNumber.outputs.result }}.arm64.rpm
|
||||
path: build/freetube-${{ steps.versionNumber.outputs.result }}.aarch64.rpm
|
||||
|
||||
- name: Upload Pacman .pacman x64 Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64')
|
||||
with:
|
||||
name: freetube-${{ steps.versionNumber.outputs.result }}-amd64.pacman
|
||||
path: build/freetube-${{ steps.versionNumber.outputs.result }}.pacman
|
||||
|
||||
# - name: Upload Web Build
|
||||
# uses: actions/upload-artifact@v4
|
||||
# uses: actions/upload-artifact@v5
|
||||
# if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64')
|
||||
# with:
|
||||
# name: freetube-${{ steps.versionNumber.outputs.result }}-static-web
|
||||
# path: dist/web
|
||||
|
||||
- name: Upload Windows x64 .exe Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-x64')
|
||||
with:
|
||||
name: freetube-${{ steps.versionNumber.outputs.result }}-setup-x64.exe
|
||||
path: build/freetube Setup ${{ steps.versionNumber.outputs.result }}.exe
|
||||
|
||||
- name: Upload Windows x64 Portable Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-x64')
|
||||
with:
|
||||
name: freetube-${{ steps.versionNumber.outputs.result }}-win-x64-portable.exe
|
||||
path: build/freetube ${{ steps.versionNumber.outputs.result }}.exe
|
||||
|
||||
- name: Upload Windows x64 .zip Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-x64')
|
||||
with:
|
||||
name: freetube-${{ steps.versionNumber.outputs.result }}-win-x64-portable.zip
|
||||
path: build/freetube-${{ steps.versionNumber.outputs.result }}-win.zip
|
||||
|
||||
- name: Upload Windows x64 .7z Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-x64')
|
||||
with:
|
||||
name: freetube-${{ steps.versionNumber.outputs.result }}-win-x64-portable.7z
|
||||
path: build/freetube-${{ steps.versionNumber.outputs.result }}-win.7z
|
||||
|
||||
- name: Upload Windows arm64 .exe Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-arm64')
|
||||
with:
|
||||
name: freetube-${{ steps.versionNumber.outputs.result }}-setup-arm64.exe
|
||||
path: build/freetube Setup ${{ steps.versionNumber.outputs.result }}.exe
|
||||
|
||||
- name: Upload Windows arm64 Portable Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-arm64')
|
||||
with:
|
||||
name: freetube-${{ steps.versionNumber.outputs.result }}-win-arm64-portable.exe
|
||||
path: build/freetube ${{ steps.versionNumber.outputs.result }}.exe
|
||||
|
||||
- name: Upload Windows arm64 .zip Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-arm64')
|
||||
with:
|
||||
name: freetube-${{ steps.versionNumber.outputs.result }}-win-arm64-portable.zip
|
||||
path: build/freetube-${{ steps.versionNumber.outputs.result }}-arm64-win.zip
|
||||
|
||||
- name: Upload Windows arm64 .7z Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-arm64')
|
||||
with:
|
||||
name: freetube-${{ steps.versionNumber.outputs.result }}-win-arm64-portable.7z
|
||||
path: build/freetube-${{ steps.versionNumber.outputs.result }}-arm64-win.7z
|
||||
|
||||
- name: Upload Mac x64 .dmg Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: startsWith(matrix.os, 'macos') && startsWith(matrix.runtime, 'osx-x64')
|
||||
with:
|
||||
name: freetube-${{ steps.versionNumber.outputs.result }}-mac-x64.dmg
|
||||
path: build/freetube-${{ steps.versionNumber.outputs.result }}.dmg
|
||||
|
||||
- name: Upload Mac x64 .zip Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: startsWith(matrix.os, 'macos') && startsWith(matrix.runtime, 'osx-x64')
|
||||
with:
|
||||
name: freetube-${{ steps.versionNumber.outputs.result }}-mac-x64.zip
|
||||
path: build/freetube-${{ steps.versionNumber.outputs.result }}-mac.zip
|
||||
|
||||
- name: Upload Mac x64 .7z Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: startsWith(matrix.os, 'macos') && startsWith(matrix.runtime, 'osx-x64')
|
||||
with:
|
||||
name: freetube-${{ steps.versionNumber.outputs.result }}-mac-x64.7z
|
||||
path: build/freetube-${{ steps.versionNumber.outputs.result }}-mac.7z
|
||||
|
||||
- name: Upload Mac arm64 .dmg Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: startsWith(matrix.os, 'macos') && startsWith(matrix.runtime, 'osx-arm64')
|
||||
with:
|
||||
name: freetube-${{ steps.versionNumber.outputs.result }}-mac-arm64.dmg
|
||||
path: build/freetube-${{ steps.versionNumber.outputs.result }}-arm64.dmg
|
||||
|
||||
- name: Upload Mac arm64 .zip Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: startsWith(matrix.os, 'macos') && startsWith(matrix.runtime, 'osx-arm64')
|
||||
with:
|
||||
name: freetube-${{ steps.versionNumber.outputs.result }}-mac-arm64.zip
|
||||
path: build/freetube-${{ steps.versionNumber.outputs.result }}-arm64-mac.zip
|
||||
|
||||
- name: Upload Mac arm64 .7z Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: startsWith(matrix.os, 'macos') && startsWith(matrix.runtime, 'osx-arm64')
|
||||
with:
|
||||
name: freetube-${{ steps.versionNumber.outputs.result }}-mac-arm64.7z
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
- name: Compress Images
|
||||
id: calibre
|
||||
uses: calibreapp/image-actions@main
|
||||
|
||||
8
.github/workflows/codeql.yml
vendored
8
.github/workflows/codeql.yml
vendored
@@ -27,11 +27,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@@ -57,6 +57,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
12
.github/workflows/flatpak.yml
vendored
12
.github/workflows/flatpak.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
repository: flathub/io.freetubeapp.FreeTube
|
||||
token: ${{ secrets.FLATHUB_TOKEN }}
|
||||
@@ -76,22 +76,22 @@ jobs:
|
||||
date +"%Y-%m-%d" >> $GITHUB_ENV
|
||||
echo 'EOF' >> $GITHUB_ENV
|
||||
- name: Update x64 File Location in yml File
|
||||
uses: mikefarah/yq@v4.47.2
|
||||
uses: mikefarah/yq@v4.49.1
|
||||
with:
|
||||
# The Command which should be run
|
||||
cmd: yq -i '.modules[0].sources[0].url = "https://github.com/FreeTubeApp/FreeTube/releases/download/v${{ steps.sub.outputs.result }}-beta/freetube-${{ steps.sub.outputs.result }}-beta-linux-x64-portable.zip"' io.freetubeapp.FreeTube.yml
|
||||
- name: Update x64 Hash in yml File
|
||||
uses: mikefarah/yq@v4.47.2
|
||||
uses: mikefarah/yq@v4.49.1
|
||||
with:
|
||||
# The Command which should be run
|
||||
cmd: yq -i '.modules[0].sources[0].sha256 = "${{ env.HASH_X64 }}"' io.freetubeapp.FreeTube.yml
|
||||
- name: Update ARM File Location in yml File
|
||||
uses: mikefarah/yq@v4.47.2
|
||||
uses: mikefarah/yq@v4.49.1
|
||||
with:
|
||||
# The Command which should be run
|
||||
cmd: yq -i '.modules[0].sources[1].url = "https://github.com/FreeTubeApp/FreeTube/releases/download/v${{ steps.sub.outputs.result }}-beta/freetube-${{ steps.sub.outputs.result }}-beta-linux-arm64-portable.zip"' io.freetubeapp.FreeTube.yml
|
||||
- name: Update ARM Hash in yml File
|
||||
uses: mikefarah/yq@v4.47.2
|
||||
uses: mikefarah/yq@v4.49.1
|
||||
with:
|
||||
# The Command which should be run
|
||||
cmd: yq -i '.modules[0].sources[1].sha256 = "${{ env.HASH_ARM64 }}"' io.freetubeapp.FreeTube.yml
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
rm freetube-${{ steps.sub.outputs.result }}-beta-linux-x64-portable.zip
|
||||
rm freetube-${{ steps.sub.outputs.result }}-beta-linux-arm64-portable.zip
|
||||
- name: Commit Files
|
||||
uses: stefanzweifel/git-auto-commit-action@v6
|
||||
uses: stefanzweifel/git-auto-commit-action@v7
|
||||
with:
|
||||
# Optional but recommended
|
||||
# Defaults to "Apply automatic changes"
|
||||
|
||||
8
.github/workflows/linter.yml
vendored
8
.github/workflows/linter.yml
vendored
@@ -17,11 +17,11 @@ jobs:
|
||||
|
||||
# Steps represent a sequence of tasks that will be executed as part of the job
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Use Node.js 24.x
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 24.x
|
||||
cache: "yarn"
|
||||
- run: yarn run ci
|
||||
env:
|
||||
|
||||
74
.github/workflows/release.yml
vendored
74
.github/workflows/release.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [22.x]
|
||||
node-version: [24.x]
|
||||
runtime:
|
||||
- linux-x64
|
||||
- linux-armv7l
|
||||
@@ -51,14 +51,14 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: 'Use faster D: drive for yarn cache on Windows'
|
||||
if: startsWith(matrix.os, 'windows')
|
||||
run: yarn config set cache-folder D:\ft_yarn_cache
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "yarn"
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
if: contains(matrix.runtime, 'arm64')
|
||||
run: yarn run build:arm64
|
||||
|
||||
- name: Convert X64 AppImage to static runtime
|
||||
- name: Convert X64 AppImage to static runtime and add update information
|
||||
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64')
|
||||
run: |
|
||||
sudo apt install desktop-file-utils
|
||||
@@ -95,11 +95,42 @@ jobs:
|
||||
appimage="FreeTube-${{ steps.getPackageInfo.outputs.version }}.AppImage"
|
||||
wget "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage" -O ./appimagetool.AppImage
|
||||
chmod +x ./"$appimage" ./appimagetool.AppImage
|
||||
update_information="gh-releases-zsync|FreeTubeApp|FreeTube|latest-all|freetube-*-amd64.AppImage.zsync"
|
||||
./"$appimage" --appimage-extract && rm -f ./"$appimage"
|
||||
./appimagetool.AppImage --comp zstd --mksquashfs-opt -Xcompression-level --mksquashfs-opt 20 \
|
||||
-n ./squashfs-root ./"$appimage"
|
||||
-u "$update_information" -n ./squashfs-root ./"$appimage"
|
||||
rm -rf ./squashfs-root ./appimagetool.AppImage
|
||||
|
||||
- name: Convert ARMv7l AppImage to static runtime and add update information
|
||||
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l')
|
||||
run: |
|
||||
sudo apt install desktop-file-utils
|
||||
cd build
|
||||
appimage="FreeTube-${{ steps.getPackageInfo.outputs.version }}.AppImage"
|
||||
wget "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage" -O ./appimagetool.AppImage
|
||||
wget "https://github.com/AppImage/type2-runtime/releases/download/continuous/runtime-x86_64" -O runtime
|
||||
chmod +x ./"$appimage" ./appimagetool.AppImage ./runtime
|
||||
update_information="gh-releases-zsync|FreeTubeApp|FreeTube|latest-all|freetube-*-armv7l.AppImage.zsync"
|
||||
TARGET_APPIMAGE=$appimage" ./runtime --appimage-extract && rm -f ./"$appimage"
|
||||
ARCH=armhf ./appimagetool.AppImage --comp zstd --mksquashfs-opt -Xcompression-level --mksquashfs-opt 20 \
|
||||
-u "$update_information" -n ./squashfs-root ./"$appimage"
|
||||
rm -rf ./squashfs-root ./appimagetool.AppImage ./runtime
|
||||
|
||||
- name: Convert ARM64 AppImage to static runtime and add update information
|
||||
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64')
|
||||
run: |
|
||||
sudo apt install desktop-file-utils
|
||||
cd build
|
||||
appimage="FreeTube-${{ steps.getPackageInfo.outputs.version }}.AppImage"
|
||||
wget "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage" -O ./appimagetool.AppImage
|
||||
wget "https://github.com/AppImage/type2-runtime/releases/download/continuous/runtime-x86_64" -O runtime
|
||||
chmod +x ./"$appimage" ./appimagetool.AppImage ./runtime
|
||||
update_information="gh-releases-zsync|FreeTubeApp|FreeTube|latest-all|freetube-*-arm64.AppImage.zsync"
|
||||
TARGET_APPIMAGE=$appimage" ./runtime --appimage-extract && rm -f ./"$appimage"
|
||||
ARCH=aarch64 ./appimagetool.AppImage --comp zstd --mksquashfs-opt -Xcompression-level --mksquashfs-opt 20 \
|
||||
-u "$update_information" -n ./squashfs-root ./"$appimage"
|
||||
rm -rf ./squashfs-root ./appimagetool.AppImage ./runtime
|
||||
|
||||
- name: Upload Linux .zip x64 Release
|
||||
uses: actions/upload-release-asset@v1
|
||||
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64')
|
||||
@@ -210,6 +241,17 @@ jobs:
|
||||
asset_path: build/FreeTube-${{ steps.getPackageInfo.outputs.version }}.AppImage
|
||||
asset_content_type: application/vnd.appimage
|
||||
|
||||
- name: Upload AppImage .zsync x64 Release
|
||||
uses: actions/upload-release-asset@v1
|
||||
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64')
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label}
|
||||
asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta-amd64.AppImage.zsync
|
||||
asset_path: build/FreeTube-${{ steps.getPackageInfo.outputs.version }}.AppImage.zsync
|
||||
asset_content_type: application/x-zsync
|
||||
|
||||
- name: Upload AppImage ARMv7l Release
|
||||
uses: actions/upload-release-asset@v1
|
||||
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l')
|
||||
@@ -221,6 +263,17 @@ jobs:
|
||||
asset_path: build/FreeTube-${{ steps.getPackageInfo.outputs.version }}-armv7l.AppImage
|
||||
asset_content_type: application/vnd.appimage
|
||||
|
||||
- name: Upload AppImage .zsync ARMv7l Release
|
||||
uses: actions/upload-release-asset@v1
|
||||
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l')
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label}
|
||||
asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta-armv7l.AppImage.zsync
|
||||
asset_path: build/FreeTube-${{ steps.getPackageInfo.outputs.version }}-armv7l.AppImage.zsync
|
||||
asset_content_type: application/x-zsync
|
||||
|
||||
- name: Upload AppImage ARM64 Release
|
||||
uses: actions/upload-release-asset@v1
|
||||
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64')
|
||||
@@ -232,6 +285,17 @@ jobs:
|
||||
asset_path: build/FreeTube-${{ steps.getPackageInfo.outputs.version }}-arm64.AppImage
|
||||
asset_content_type: application/vnd.appimage
|
||||
|
||||
- name: Upload AppImage .zsync ARM64 Release
|
||||
uses: actions/upload-release-asset@v1
|
||||
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64')
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: https://uploads.github.com/repos/FreeTubeApp/FreeTube/releases/${{ inputs.releaseId }}/assets{?name,label}
|
||||
asset_name: freetube-${{ steps.getPackageInfo.outputs.version }}-beta-arm64.AppImage.zsync
|
||||
asset_path: build/FreeTube-${{ steps.getPackageInfo.outputs.version }}-arm64.AppImage.zsync
|
||||
asset_content_type: application/x-zsync
|
||||
|
||||
- name: Upload Linux .rpm x64 Release
|
||||
uses: actions/upload-release-asset@v1
|
||||
if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64')
|
||||
|
||||
4
.github/workflows/updateSite.yml
vendored
4
.github/workflows/updateSite.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
repository: FreeTubeApp/FreeTubeApp.io
|
||||
token: ${{ secrets.FLATHUB_TOKEN }}
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
run: |
|
||||
sed -i 's/${{ steps.previous.outputs.result }}/${{ steps.current.outputs.result }}/g' src/index.php
|
||||
- name: Commit Files
|
||||
uses: stefanzweifel/git-auto-commit-action@v6
|
||||
uses: stefanzweifel/git-auto-commit-action@v7
|
||||
with:
|
||||
# Optional but recommended
|
||||
# Defaults to "Apply automatic changes"
|
||||
|
||||
@@ -16,9 +16,9 @@ Please follow these guidelines before sending your pull request and making contr
|
||||
* Stick to a similar style of code already in the project. Please look at current code to get an idea on how to do this.
|
||||
* Follow [ES6](https://rse.github.io/es6-features/) standards in your code. Ex: Use `let` and `const` instead of `var`. Do not use `function(response){//code}` for callbacks, use `(response) => {//code}`.
|
||||
* Comment your code when necessary. Follow the [JavaScript Documentation and Comments Standard](https://www.drupal.org/docs/develop/standards/javascript/javascript-api-documentation-and-comment-standards) for functions.
|
||||
* Please follow proper Vue structure when creating new code / components. Use existing code as well as the [Vue.js Guide](https://vuejs.org/v2/guide/) for reference.
|
||||
* Please follow proper Vue structure when creating new code / components. Use existing code as well as the [Vue.js Guide](https://vuejs.org/guide/introduction.html) for reference.
|
||||
* Please test your code. Make sure new features work as well as existing core features such as watching videos or loading subscriptions. New features need to work with both the Local API as well as the Invidious API
|
||||
* Please make sure your code does not violate any standards set by our linter. It's up to you to make fixes whenever necessary. You can run `npm run lint` to check locally and `npm run lint-fix` to automatically fix smaller issues.
|
||||
* Please make sure your code does not violate any standards set by our linter. It's up to you to make fixes whenever necessary. You can run `yarn run lint` to check locally and `yarn run lint-fix` to automatically fix smaller issues.
|
||||
* Please limit the amount of Node Modules that you introduce into the project. Only include them when **absolutely necessary** for your code to work (Ex: Using nedb for databases) or if a module provides similar functionality to what you are trying to achieve (Ex: Using autolinker to create links to outside URLs instead of writing the functionality myself).
|
||||
* Please try to stay involved with the community and maintain your code. We are only a handful of developers working on FreeTube in our spare time. We do not have time to work on everything, and it would be nice if you can maintain your code when necessary.
|
||||
|
||||
|
||||
1
_icons/iconCatppuccinLatteDarkSmall.svg
Normal file
1
_icons/iconCatppuccinLatteDarkSmall.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="25" height="25" xmlns="http://www.w3.org/2000/svg" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"><path d="M4.903 0C5.84 0 6.6.76 6.6 1.697V25H4.27A4.268 4.268 0 0 1 0 20.73V1.697C0 .76.76 0 1.697 0h3.206zM25 0v2.022A4.577 4.577 0 0 1 20.422 6.6H9.38A1.68 1.68 0 0 1 7.7 4.92V1.68C7.7.752 8.452 0 9.38 0H25zm-7.064 12.223a.645.645 0 0 1 0 1.154l-9.273 4.596a.668.668 0 0 1-.963-.597V8.224a.667.667 0 0 1 .963-.597l9.273 4.596z" style="fill:#4c4f69"/></svg>
|
||||
|
After Width: | Height: | Size: 508 B |
1
_icons/iconCatppuccinLatteLightSmall.svg
Normal file
1
_icons/iconCatppuccinLatteLightSmall.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="25" height="25" xmlns="http://www.w3.org/2000/svg" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"><path d="M4.903 0C5.84 0 6.6.76 6.6 1.697V25H4.27A4.268 4.268 0 0 1 0 20.73V1.697C0 .76.76 0 1.697 0h3.206zM25 0v2.022A4.577 4.577 0 0 1 20.422 6.6H9.38A1.68 1.68 0 0 1 7.7 4.92V1.68C7.7.752 8.452 0 9.38 0H25zm-7.064 12.223a.645.645 0 0 1 0 1.154l-9.273 4.596a.668.668 0 0 1-.963-.597V8.224a.667.667 0 0 1 .963-.597l9.273 4.596z" style="fill:#eff1f5"/></svg>
|
||||
|
After Width: | Height: | Size: 508 B |
1
_icons/textCatppuccinLatteDarkSmall.svg
Normal file
1
_icons/textCatppuccinLatteDarkSmall.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="100" height="49" xmlns="http://www.w3.org/2000/svg" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"><path d="M3.029 33.471c-.452 0-.815-.141-1.088-.422-.272-.282-.409-.65-.409-1.104V18.21c0-.454.129-.806.386-1.057.257-.25.612-.376 1.064-.376h8.204c.951 0 1.426.407 1.426 1.221 0 .799-.475 1.198-1.426 1.198H4.501v4.508h6.218c.95 0 1.425.407 1.425 1.221 0 .798-.475 1.197-1.425 1.197H4.501v5.823c0 .454-.132.822-.397 1.104-.265.281-.624.422-1.075.422zm16.403-11.857c.367-.031.656.055.868.258.211.204.317.509.317.916 0 .423-.092.736-.275.939-.183.204-.515.337-.994.399l-.635.071c-.832.094-1.441.407-1.829.939-.388.532-.582 1.197-.582 1.996v4.93c0 .454-.127.803-.381 1.045-.253.243-.571.364-.951.364-.381 0-.695-.121-.941-.364-.247-.242-.37-.591-.37-1.045v-9.086c0-.438.123-.775.37-1.01.246-.234.553-.352.92-.352.366 0 .662.114.888.34.226.228.338.552.338.975v.939c.268-.689.667-1.221 1.195-1.597a3.419 3.419 0 0 1 1.766-.633l.296-.024zm12.444 8.57c.277 0 .501.102.672.305.171.204.257.478.257.822 0 .485-.302.892-.905 1.221a8.382 8.382 0 0 1-1.882.716 8.06 8.06 0 0 1-2.004.27c-1.923 0-3.447-.532-4.571-1.596-1.125-1.065-1.687-2.521-1.687-4.368 0-1.174.244-2.215.734-3.122a5.2 5.2 0 0 1 2.065-2.113c.888-.502 1.894-.752 3.019-.752 1.075 0 2.012.227 2.811.681a4.7 4.7 0 0 1 1.857 1.925c.44.83.66 1.808.66 2.935 0 .673-.309 1.01-.928 1.01h-7.211c.097 1.08.415 1.874.953 2.383.538.509 1.32.763 2.347.763.521 0 .981-.063 1.381-.188.399-.125.851-.297 1.356-.516.489-.251.848-.376 1.076-.376zm-4.229-6.551c-.831 0-1.495.251-1.992.752-.497.501-.795 1.22-.892 2.16h5.524c-.033-.955-.277-1.679-.733-2.172-.457-.493-1.092-.74-1.907-.74zm17.487 6.551c.277 0 .501.102.672.305.171.204.257.478.257.822 0 .485-.302.892-.905 1.221a8.382 8.382 0 0 1-1.882.716c-.7.18-1.369.27-2.004.27-1.923 0-3.447-.532-4.571-1.596-1.125-1.065-1.687-2.521-1.687-4.368 0-1.174.245-2.215.734-3.122a5.2 5.2 0 0 1 2.065-2.113c.888-.502 1.894-.752 3.019-.752 1.075 0 2.012.227 2.811.681a4.7 4.7 0 0 1 1.857 1.925c.44.83.66 1.808.66 2.935 0 .673-.309 1.01-.928 1.01h-7.211c.098 1.08.415 1.874.953 2.383.538.509 1.32.763 2.347.763.521 0 .981-.063 1.381-.188.399-.125.851-.297 1.356-.516.489-.251.848-.376 1.076-.376zm-4.229-6.551c-.831 0-1.495.251-1.992.752-.497.501-.794 1.22-.892 2.16h5.524c-.033-.955-.277-1.679-.733-2.172-.456-.493-1.092-.74-1.907-.74zm13.402 9.838c-.521 0-1.186-.164-1.506-.494-.32-.329-.48-.768-.48-1.316V19.763h-3.374c-1.102 0-1.652-.501-1.652-1.504 0-.988.55-1.482 1.652-1.482h10.719c1.102 0 1.653.494 1.653 1.482 0 1.003-.551 1.504-1.653 1.504h-3.374v11.898c0 .548-.156.987-.468 1.316-.313.33-.981.494-1.517.494zM69.693 21.69c.545 0 .978.148 1.297.446.32.297.479.704.479 1.221v8.616c0 .486-.167.877-.502 1.175-.335.297-.768.446-1.297.446-.499 0-.896-.141-1.192-.423-.297-.282-.445-.658-.445-1.127v-.235a3.63 3.63 0 0 1-1.414 1.362c-.584.313-1.242.469-1.975.469-1.449 0-2.528-.403-3.237-1.209-.709-.806-1.064-2.023-1.064-3.651v-5.423c0-.517.16-.924.479-1.221.32-.298.752-.446 1.298-.446.545 0 .974.148 1.285.446.312.297.468.704.468 1.221v5.494c0 .688.144 1.197.432 1.526.288.329.729.493 1.321.493.685 0 1.243-.235 1.671-.704.429-.47.643-1.088.643-1.855v-4.954c0-.517.156-.924.467-1.221.312-.298.741-.446 1.286-.446zm11.736-.047c1.017 0 1.915.242 2.694.727.779.486 1.387 1.178 1.824 2.079.437.899.655 1.944.655 3.134s-.218 2.242-.655 3.158c-.437.915-1.049 1.628-1.836 2.137-.787.508-1.681.762-2.682.762-.811 0-1.542-.168-2.194-.504a3.633 3.633 0 0 1-1.502-1.397v.187c0 .501-.159.904-.477 1.21-.317.305-.747.458-1.287.458-.54 0-.974-.153-1.299-.458-.326-.306-.489-.709-.489-1.21V18.402c0-.485.171-.876.512-1.173.342-.298.791-.447 1.347-.447.525 0 .946.141 1.264.423.318.282.477.657.477 1.127v5.142a3.534 3.534 0 0 1 1.49-1.35c.644-.321 1.363-.481 2.158-.481zm-1.049 9.298c.842 0 1.494-.294 1.955-.881.461-.587.691-1.412.691-2.477 0-1.049-.23-1.851-.691-2.407-.461-.555-1.113-.833-1.955-.833-.843 0-1.494.286-1.955.857-.461.571-.692 1.381-.692 2.43 0 1.064.231 1.882.692 2.454.461.571 1.112.857 1.955.857zm18.404-.916c.337 0 .612.125.823.375.211.251.317.572.317.963 0 .266-.084.513-.254.74a2.005 2.005 0 0 1-.709.575 9.445 9.445 0 0 1-2.002.692c-.76.181-1.461.27-2.103.27-1.351 0-2.53-.242-3.535-.727-1.005-.486-1.778-1.178-2.318-2.078-.541-.9-.811-1.961-.811-3.182 0-1.174.262-2.214.786-3.122a5.526 5.526 0 0 1 2.179-2.125c.929-.509 1.984-.763 3.167-.763 1.132 0 2.124.231 2.977.692a4.895 4.895 0 0 1 1.989 1.973c.473.853.71 1.85.71 2.993 0 .345-.089.607-.266.787-.177.18-.427.27-.748.27h-7.095c.119.923.423 1.593.913 2.007.49.415 1.191.622 2.103.622.49 0 .929-.054 1.317-.164.389-.11.82-.258 1.293-.446.236-.094.464-.176.684-.247.219-.07.414-.105.583-.105zm-4.359-5.941c-.726 0-1.309.216-1.748.646-.439.431-.701 1.053-.786 1.867h4.891c-.051-.83-.275-1.456-.672-1.878-.397-.423-.958-.635-1.685-.635z" style="fill-rule:nonzero;fill:#4c4f69"/></svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
1
_icons/textCatppuccinLatteLightSmall.svg
Normal file
1
_icons/textCatppuccinLatteLightSmall.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="100" height="49" xmlns="http://www.w3.org/2000/svg" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"><path d="M3.029 33.471c-.452 0-.815-.141-1.088-.422-.272-.282-.409-.65-.409-1.104V18.21c0-.454.129-.806.386-1.057.257-.25.612-.376 1.064-.376h8.204c.951 0 1.426.407 1.426 1.221 0 .799-.475 1.198-1.426 1.198H4.501v4.508h6.218c.95 0 1.425.407 1.425 1.221 0 .798-.475 1.197-1.425 1.197H4.501v5.823c0 .454-.132.822-.397 1.104-.265.281-.624.422-1.075.422zm16.403-11.857c.367-.031.656.055.868.258.211.204.317.509.317.916 0 .423-.092.736-.275.939-.183.204-.515.337-.994.399l-.635.071c-.832.094-1.441.407-1.829.939-.388.532-.582 1.197-.582 1.996v4.93c0 .454-.127.803-.381 1.045-.253.243-.571.364-.951.364-.381 0-.695-.121-.941-.364-.247-.242-.37-.591-.37-1.045v-9.086c0-.438.123-.775.37-1.01.246-.234.553-.352.92-.352.366 0 .662.114.888.34.226.228.338.552.338.975v.939c.268-.689.667-1.221 1.195-1.597a3.419 3.419 0 0 1 1.766-.633l.296-.024zm12.444 8.57c.277 0 .501.102.672.305.171.204.257.478.257.822 0 .485-.302.892-.905 1.221a8.382 8.382 0 0 1-1.882.716 8.06 8.06 0 0 1-2.004.27c-1.923 0-3.447-.532-4.571-1.596-1.125-1.065-1.687-2.521-1.687-4.368 0-1.174.244-2.215.734-3.122a5.2 5.2 0 0 1 2.065-2.113c.888-.502 1.894-.752 3.019-.752 1.075 0 2.012.227 2.811.681a4.7 4.7 0 0 1 1.857 1.925c.44.83.66 1.808.66 2.935 0 .673-.309 1.01-.928 1.01h-7.211c.097 1.08.415 1.874.953 2.383.538.509 1.32.763 2.347.763.521 0 .981-.063 1.381-.188.399-.125.851-.297 1.356-.516.489-.251.848-.376 1.076-.376zm-4.229-6.551c-.831 0-1.495.251-1.992.752-.497.501-.795 1.22-.892 2.16h5.524c-.033-.955-.277-1.679-.733-2.172-.457-.493-1.092-.74-1.907-.74zm17.487 6.551c.277 0 .501.102.672.305.171.204.257.478.257.822 0 .485-.302.892-.905 1.221a8.382 8.382 0 0 1-1.882.716c-.7.18-1.369.27-2.004.27-1.923 0-3.447-.532-4.571-1.596-1.125-1.065-1.687-2.521-1.687-4.368 0-1.174.245-2.215.734-3.122a5.2 5.2 0 0 1 2.065-2.113c.888-.502 1.894-.752 3.019-.752 1.075 0 2.012.227 2.811.681a4.7 4.7 0 0 1 1.857 1.925c.44.83.66 1.808.66 2.935 0 .673-.309 1.01-.928 1.01h-7.211c.098 1.08.415 1.874.953 2.383.538.509 1.32.763 2.347.763.521 0 .981-.063 1.381-.188.399-.125.851-.297 1.356-.516.489-.251.848-.376 1.076-.376zm-4.229-6.551c-.831 0-1.495.251-1.992.752-.497.501-.794 1.22-.892 2.16h5.524c-.033-.955-.277-1.679-.733-2.172-.456-.493-1.092-.74-1.907-.74zm13.402 9.838c-.521 0-1.186-.164-1.506-.494-.32-.329-.48-.768-.48-1.316V19.763h-3.374c-1.102 0-1.652-.501-1.652-1.504 0-.988.55-1.482 1.652-1.482h10.719c1.102 0 1.653.494 1.653 1.482 0 1.003-.551 1.504-1.653 1.504h-3.374v11.898c0 .548-.156.987-.468 1.316-.313.33-.981.494-1.517.494zM69.693 21.69c.545 0 .978.148 1.297.446.32.297.479.704.479 1.221v8.616c0 .486-.167.877-.502 1.175-.335.297-.768.446-1.297.446-.499 0-.896-.141-1.192-.423-.297-.282-.445-.658-.445-1.127v-.235a3.63 3.63 0 0 1-1.414 1.362c-.584.313-1.242.469-1.975.469-1.449 0-2.528-.403-3.237-1.209-.709-.806-1.064-2.023-1.064-3.651v-5.423c0-.517.16-.924.479-1.221.32-.298.752-.446 1.298-.446.545 0 .974.148 1.285.446.312.297.468.704.468 1.221v5.494c0 .688.144 1.197.432 1.526.288.329.729.493 1.321.493.685 0 1.243-.235 1.671-.704.429-.47.643-1.088.643-1.855v-4.954c0-.517.156-.924.467-1.221.312-.298.741-.446 1.286-.446zm11.736-.047c1.017 0 1.915.242 2.694.727.779.486 1.387 1.178 1.824 2.079.437.899.655 1.944.655 3.134s-.218 2.242-.655 3.158c-.437.915-1.049 1.628-1.836 2.137-.787.508-1.681.762-2.682.762-.811 0-1.542-.168-2.194-.504a3.633 3.633 0 0 1-1.502-1.397v.187c0 .501-.159.904-.477 1.21-.317.305-.747.458-1.287.458-.54 0-.974-.153-1.299-.458-.326-.306-.489-.709-.489-1.21V18.402c0-.485.171-.876.512-1.173.342-.298.791-.447 1.347-.447.525 0 .946.141 1.264.423.318.282.477.657.477 1.127v5.142a3.534 3.534 0 0 1 1.49-1.35c.644-.321 1.363-.481 2.158-.481zm-1.049 9.298c.842 0 1.494-.294 1.955-.881.461-.587.691-1.412.691-2.477 0-1.049-.23-1.851-.691-2.407-.461-.555-1.113-.833-1.955-.833-.843 0-1.494.286-1.955.857-.461.571-.692 1.381-.692 2.43 0 1.064.231 1.882.692 2.454.461.571 1.112.857 1.955.857zm18.404-.916c.337 0 .612.125.823.375.211.251.317.572.317.963 0 .266-.084.513-.254.74a2.005 2.005 0 0 1-.709.575 9.445 9.445 0 0 1-2.002.692c-.76.181-1.461.27-2.103.27-1.351 0-2.53-.242-3.535-.727-1.005-.486-1.778-1.178-2.318-2.078-.541-.9-.811-1.961-.811-3.182 0-1.174.262-2.214.786-3.122a5.526 5.526 0 0 1 2.179-2.125c.929-.509 1.984-.763 3.167-.763 1.132 0 2.124.231 2.977.692a4.895 4.895 0 0 1 1.989 1.973c.473.853.71 1.85.71 2.993 0 .345-.089.607-.266.787-.177.18-.427.27-.748.27h-7.095c.119.923.423 1.593.913 2.007.49.415 1.191.622 2.103.622.49 0 .929-.054 1.317-.164.389-.11.82-.258 1.293-.446.236-.094.464-.176.684-.247.219-.07.414-.105.583-.105zm-4.359-5.941c-.726 0-1.309.216-1.748.646-.439.431-.701 1.053-.786 1.867h4.891c-.051-.83-.275-1.456-.672-1.878-.397-.423-.958-.635-1.685-.635z" style="fill-rule:nonzero;fill:#eff1f5"/></svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
@@ -1,60 +1,12 @@
|
||||
// This script fixes shaka not exporting its type definitions and referencing the Roboto font on google fonts in its CSS
|
||||
// by adding an export line to the type definitions and updating the CSS to point to the local Roboto font
|
||||
// This script fixes shaka-player referencing the Roboto font on google fonts in its CSS
|
||||
// by updating the CSS to point to the local Roboto font
|
||||
// this script only makes changes if they are needed, so running it multiple times doesn't cause any problems
|
||||
|
||||
import { appendFileSync, closeSync, ftruncateSync, openSync, readFileSync, writeSync } from 'fs'
|
||||
import { closeSync, ftruncateSync, openSync, readFileSync, writeSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
|
||||
const SHAKA_DIST_DIR = resolve(import.meta.dirname, '../node_modules/shaka-player/dist')
|
||||
|
||||
function fixTypes() {
|
||||
let fixedTypes = false
|
||||
|
||||
let fileHandleNormal
|
||||
try {
|
||||
fileHandleNormal = openSync(`${SHAKA_DIST_DIR}/shaka-player.ui.d.ts`, 'a+')
|
||||
|
||||
const contents = readFileSync(fileHandleNormal, 'utf-8')
|
||||
|
||||
// This script is run after every `yarn install`, even if shaka-player wasn't updated
|
||||
// So we want to check first, if we actually need to make any changes
|
||||
// or if the ones from the previous run are still intact
|
||||
if (!contents.includes('export default shaka')) {
|
||||
appendFileSync(fileHandleNormal, 'export default shaka;\n')
|
||||
|
||||
fixedTypes = true
|
||||
}
|
||||
} finally {
|
||||
if (typeof fileHandleNormal !== 'undefined') {
|
||||
closeSync(fileHandleNormal)
|
||||
}
|
||||
}
|
||||
|
||||
let fileHandleDebug
|
||||
try {
|
||||
fileHandleDebug = openSync(`${SHAKA_DIST_DIR}/shaka-player.ui.debug.d.ts`, 'a+')
|
||||
|
||||
const contents = readFileSync(fileHandleDebug, 'utf-8')
|
||||
|
||||
// This script is run after every `yarn install`, even if shaka-player wasn't updated
|
||||
// So we want to check first, if we actually need to make any changes
|
||||
// or if the ones from the previous run are still intact
|
||||
if (!contents.includes('export default shaka')) {
|
||||
appendFileSync(fileHandleDebug, 'export default shaka;\n')
|
||||
|
||||
fixedTypes = true
|
||||
}
|
||||
} finally {
|
||||
if (typeof fileHandleDebug !== 'undefined') {
|
||||
closeSync(fileHandleDebug)
|
||||
}
|
||||
}
|
||||
|
||||
if (fixedTypes) {
|
||||
console.log('Fixed shaka-player types')
|
||||
}
|
||||
}
|
||||
|
||||
function removeRobotoFont() {
|
||||
let cssFileHandle
|
||||
try {
|
||||
@@ -72,11 +24,10 @@ function removeRobotoFont() {
|
||||
console.log('Removed shaka-player Roboto font, so it uses ours')
|
||||
}
|
||||
} finally {
|
||||
if (typeof cssFileHandle !== 'undefined') {
|
||||
if (cssFileHandle !== undefined) {
|
||||
closeSync(cssFileHandle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fixTypes()
|
||||
removeRobotoFont()
|
||||
|
||||
@@ -5,9 +5,7 @@ const { readFileSync } = require('fs')
|
||||
const path = join(__dirname, '../src/renderer/sigFrameScript.js')
|
||||
const rawScript = readFileSync(path, 'utf8')
|
||||
|
||||
const script = process.env.NODE_ENV === 'development'
|
||||
? rawScript
|
||||
: require('terser').minify_sync({ [path]: rawScript }).code
|
||||
const script = require('terser').minify_sync({ [path]: rawScript }).code
|
||||
|
||||
module.exports.sigFrameTemplateParameters = {
|
||||
sigFrameSrc: `data:text/html,${encodeURIComponent(`<!doctype html><script>${script}</script>`)}`,
|
||||
|
||||
@@ -2,7 +2,7 @@ const path = require('path')
|
||||
const { readFileSync, readdirSync } = require('fs')
|
||||
const webpack = require('webpack')
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin')
|
||||
const VueLoaderPlugin = require('vue-loader/lib/plugin')
|
||||
const { VueLoaderPlugin } = require('vue-loader')
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
|
||||
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')
|
||||
const ProcessLocalesPlugin = require('./ProcessLocalesPlugin')
|
||||
@@ -56,7 +56,7 @@ const config = {
|
||||
loader: 'vue-loader',
|
||||
options: {
|
||||
compilerOptions: {
|
||||
whitespace: 'condense',
|
||||
isCustomElement: (tag) => tag === 'swiper-container' || tag === 'swiper-slide'
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -133,6 +133,12 @@ const config = {
|
||||
'process.env.IS_ELECTRON': true,
|
||||
'process.env.IS_ELECTRON_MAIN': false,
|
||||
'process.env.SUPPORTS_LOCAL_API': true,
|
||||
__VUE_OPTIONS_API__: 'true',
|
||||
__VUE_PROD_DEVTOOLS__: 'false',
|
||||
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false',
|
||||
__VUE_I18N_LEGACY_API__: 'true',
|
||||
__VUE_I18N_FULL_INSTALL__: 'false',
|
||||
__INTLIFY_PROD_DEVTOOLS__: 'false',
|
||||
'process.env.LOCALE_NAMES': JSON.stringify(processLocalesPlugin.localeNames),
|
||||
'process.env.GEOLOCATION_NAMES': JSON.stringify(readdirSync(path.join(__dirname, '..', 'static', 'geolocations')).map(filename => filename.replace('.json', ''))),
|
||||
'process.env.SWIPER_VERSION': `'${swiperVersion}'`,
|
||||
@@ -180,9 +186,6 @@ const config = {
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
vue$: 'vue/dist/vue.runtime.esm.js',
|
||||
'portal-vue$': 'portal-vue/dist/portal-vue.esm.js',
|
||||
|
||||
DB_HANDLERS_ELECTRON_RENDERER_OR_WEB$: path.resolve(__dirname, '../src/datastores/handlers/electron.js'),
|
||||
|
||||
'youtubei.js$': 'youtubei.js/web',
|
||||
|
||||
@@ -2,7 +2,7 @@ const path = require('path')
|
||||
const fs = require('fs')
|
||||
const webpack = require('webpack')
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin')
|
||||
const VueLoaderPlugin = require('vue-loader/lib/plugin')
|
||||
const { VueLoaderPlugin } = require('vue-loader')
|
||||
const CopyWebpackPlugin = require('copy-webpack-plugin')
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
|
||||
const JsonMinimizerPlugin = require('json-minimizer-webpack-plugin')
|
||||
@@ -45,7 +45,7 @@ const config = {
|
||||
loader: 'vue-loader',
|
||||
options: {
|
||||
compilerOptions: {
|
||||
whitespace: 'condense',
|
||||
isCustomElement: (tag) => tag === 'swiper-container' || tag === 'swiper-slide',
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -128,6 +128,12 @@ const config = {
|
||||
'process.env.IS_ELECTRON': false,
|
||||
'process.env.IS_ELECTRON_MAIN': false,
|
||||
'process.env.SUPPORTS_LOCAL_API': false,
|
||||
__VUE_OPTIONS_API__: 'true',
|
||||
__VUE_PROD_DEVTOOLS__: 'false',
|
||||
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false',
|
||||
__VUE_I18N_LEGACY_API__: 'true',
|
||||
__VUE_I18N_FULL_INSTALL__: 'false',
|
||||
__INTLIFY_PROD_DEVTOOLS__: 'false',
|
||||
'process.env.SWIPER_VERSION': `'${swiperVersion}'`
|
||||
}),
|
||||
new webpack.ProvidePlugin({
|
||||
@@ -158,9 +164,6 @@ const config = {
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
vue$: 'vue/dist/vue.runtime.esm.js',
|
||||
'portal-vue$': 'portal-vue/dist/portal-vue.esm.js',
|
||||
|
||||
DB_HANDLERS_ELECTRON_RENDERER_OR_WEB$: path.resolve(__dirname, '../src/datastores/handlers/web.js'),
|
||||
|
||||
// change to "shaka-player.ui.debug.js" to get debug logs (update jsconfig to get updated types)
|
||||
|
||||
@@ -33,9 +33,9 @@ export default [
|
||||
ts: false,
|
||||
}),
|
||||
js.configs.recommended,
|
||||
...eslintPluginVue.configs['flat/vue2-recommended'],
|
||||
...eslintPluginVue.configs['flat/recommended'],
|
||||
...vuejsAccessibility.configs["flat/recommended"],
|
||||
...intlifyVueI18N.configs['flat/recommended'],
|
||||
...intlifyVueI18N.configs['recommended'],
|
||||
{
|
||||
files: [
|
||||
'**/*.{js,vue}',
|
||||
@@ -63,7 +63,7 @@ export default [
|
||||
settings: {
|
||||
'vue-i18n': {
|
||||
localeDir: `./static/locales/{${activeLocales.join(',')}}.yaml`,
|
||||
messageSyntaxVersion: '^8.0.0',
|
||||
messageSyntaxVersion: '^11.0.0',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -79,7 +79,6 @@ export default [
|
||||
'no-unused-vars': 'warn',
|
||||
'no-undef': 'warn',
|
||||
'object-shorthand': 'off',
|
||||
'vue/no-template-key': 'warn',
|
||||
'vue/multi-word-component-names': 'off',
|
||||
|
||||
'vuejs-accessibility/label-has-for': ['error', {
|
||||
@@ -119,9 +118,8 @@ export default [
|
||||
ignoreText: ['-', '•', '/', 'YouTube', 'Invidious', 'FreeTube'],
|
||||
}],
|
||||
|
||||
'@intlify/vue-i18n/no-deprecated-tc': 'off',
|
||||
'vue/require-explicit-emits': 'error',
|
||||
'vue/no-unused-emit-declarations': 'error',
|
||||
'vue/prefer-use-template-ref': 'error',
|
||||
|
||||
'jsdoc/check-alignment': 'error',
|
||||
'jsdoc/check-property-names': 'error',
|
||||
@@ -166,7 +164,7 @@ export default [
|
||||
settings: {
|
||||
'vue-i18n': {
|
||||
localeDir: `./static/locales/{${activeLocales.join(',')}}.yaml`,
|
||||
messageSyntaxVersion: '^8.0.0',
|
||||
messageSyntaxVersion: '^11.0.0',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -191,7 +189,7 @@ export default [
|
||||
settings: {
|
||||
'vue-i18n': {
|
||||
localeDir: `./static/locales/{${activeLocales.join(',')}}.yaml`,
|
||||
messageSyntaxVersion: '^8.0.0',
|
||||
messageSyntaxVersion: '^11.0.0',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -210,7 +208,7 @@ export default [
|
||||
settings: {
|
||||
'vue-i18n': {
|
||||
localeDir: `./static/locales/{${activeLocales.join(',')}}.yaml`,
|
||||
messageSyntaxVersion: '^8.0.0',
|
||||
messageSyntaxVersion: '^11.0.0',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"vueCompilerOptions": {
|
||||
"target": 2.7
|
||||
"target": 3.5
|
||||
},
|
||||
"compilerOptions": {
|
||||
"strictNullChecks": true,
|
||||
|
||||
64
package.json
64
package.json
@@ -2,7 +2,7 @@
|
||||
"name": "freetube",
|
||||
"productName": "FreeTube",
|
||||
"description": "A private YouTube client",
|
||||
"version": "0.23.10",
|
||||
"version": "0.23.12",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"main": "./dist/main.js",
|
||||
"private": true,
|
||||
@@ -58,64 +58,62 @@
|
||||
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||
"@fortawesome/vue-fontawesome": "^2.0.10",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.8",
|
||||
"@seald-io/nedb": "^4.1.2",
|
||||
"autolinker": "^4.1.5",
|
||||
"bgutils-js": "^3.2.0",
|
||||
"electron-context-menu": "^4.1.1",
|
||||
"marked": "^16.4.0",
|
||||
"portal-vue": "^2.1.7",
|
||||
"marked": "^17.0.1",
|
||||
"process": "^0.11.10",
|
||||
"shaka-player": "^4.16.4",
|
||||
"swiper": "^12.0.2",
|
||||
"vue": "^2.7.16",
|
||||
"vue-i18n": "^8.28.2",
|
||||
"vue-observe-visibility": "^1.0.0",
|
||||
"vue-router": "^3.6.5",
|
||||
"vuex": "^3.6.2",
|
||||
"youtubei.js": "^16.0.0"
|
||||
"shaka-player": "^4.16.10",
|
||||
"swiper": "^12.0.3",
|
||||
"vue": "^3.5.25",
|
||||
"vue-i18n": "^11.1.12",
|
||||
"vue-observe-visibility": "^2.0.0-alpha.1",
|
||||
"vue-router": "^4.6.3",
|
||||
"vuex": "^4.1.0",
|
||||
"youtubei.js": "^16.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.28.4",
|
||||
"@babel/preset-env": "^7.28.3",
|
||||
"@double-great/stylelint-a11y": "^3.4.0",
|
||||
"@eslint/js": "^9.37.0",
|
||||
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
|
||||
"@babel/core": "^7.28.5",
|
||||
"@babel/preset-env": "^7.28.5",
|
||||
"@double-great/stylelint-a11y": "^3.4.1",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@intlify/eslint-plugin-vue-i18n": "^4.1.0",
|
||||
"babel-loader": "^10.0.0",
|
||||
"copy-webpack-plugin": "^13.0.1",
|
||||
"css-loader": "^7.1.2",
|
||||
"css-minimizer-webpack-plugin": "^7.0.2",
|
||||
"electron": "^38.2.2",
|
||||
"electron-builder": "^26.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint-plugin-jsdoc": "^61.1.1",
|
||||
"electron": "^39.2.3",
|
||||
"electron-builder": "^26.3.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-jsdoc": "^61.4.1",
|
||||
"eslint-plugin-jsonc": "^2.21.0",
|
||||
"eslint-plugin-unicorn": "^61.0.2",
|
||||
"eslint-plugin-vue": "^10.5.0",
|
||||
"eslint-plugin-unicorn": "^62.0.0",
|
||||
"eslint-plugin-vue": "^10.6.0",
|
||||
"eslint-plugin-vuejs-accessibility": "^2.4.1",
|
||||
"eslint-plugin-yml": "^1.19.0",
|
||||
"globals": "^16.4.0",
|
||||
"html-webpack-plugin": "^5.6.4",
|
||||
"js-yaml": "^4.1.0",
|
||||
"globals": "^16.5.0",
|
||||
"html-webpack-plugin": "^5.6.5",
|
||||
"js-yaml": "^4.1.1",
|
||||
"json-minimizer-webpack-plugin": "^5.0.1",
|
||||
"lefthook": "^1.13.6",
|
||||
"lefthook": "^2.0.4",
|
||||
"mini-css-extract-plugin": "^2.9.4",
|
||||
"neostandard": "^0.12.2",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-scss": "^4.0.9",
|
||||
"sass": "^1.93.2",
|
||||
"sass-loader": "^16.0.5",
|
||||
"stylelint": "^16.25.0",
|
||||
"sass": "^1.94.2",
|
||||
"sass-loader": "^16.0.6",
|
||||
"stylelint": "^16.26.0",
|
||||
"stylelint-config-sass-guidelines": "^12.1.0",
|
||||
"stylelint-config-standard": "^39.0.1",
|
||||
"stylelint-high-performance-animation": "^1.11.0",
|
||||
"stylelint-use-logical-spec": "^5.0.1",
|
||||
"tree-kill": "1.2.2",
|
||||
"vue-devtools": "^5.1.4",
|
||||
"vue-eslint-parser": "^10.2.0",
|
||||
"vue-loader": "^15.10.0",
|
||||
"webpack": "^5.102.1",
|
||||
"vue-loader": "^17.4.2",
|
||||
"webpack": "^5.103.0",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.2",
|
||||
"yaml-eslint-parser": "^1.3.0"
|
||||
|
||||
@@ -6,10 +6,9 @@ import { BG, buildURL, GOOG_API_KEY } from 'bgutils-js'
|
||||
/**
|
||||
* Based on: https://github.com/LuanRT/BgUtils/blob/main/examples/node/innertube-challenge-fetcher-example.ts
|
||||
* @param {string} videoId
|
||||
* @param {string} visitorData
|
||||
* @param {import('youtubei.js').Session['context']} context
|
||||
*/
|
||||
export default async function (videoId, visitorData, context) {
|
||||
export default async function (videoId, context) {
|
||||
const requestKey = 'O43z0dpjhgX20SCx4KAo'
|
||||
|
||||
const challengeResponse = await fetch(
|
||||
@@ -19,7 +18,7 @@ export default async function (videoId, visitorData, context) {
|
||||
headers: {
|
||||
Accept: '*/*',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Goog-Visitor-Id': visitorData,
|
||||
'X-Goog-Visitor-Id': context.client.visitorData,
|
||||
'X-Youtube-Client-Version': context.client.clientVersion,
|
||||
'X-Youtube-Client-Name': '1'
|
||||
},
|
||||
@@ -83,8 +82,5 @@ export default async function (videoId, visitorData, context) {
|
||||
|
||||
const integrityTokenBasedMinter = await BG.WebPoMinter.create({ integrityToken: response[0] }, webPoSignalOutput)
|
||||
|
||||
const contentPoToken = await integrityTokenBasedMinter.mintAsWebsafeString(videoId)
|
||||
const sessionPoToken = await integrityTokenBasedMinter.mintAsWebsafeString(visitorData)
|
||||
|
||||
return { contentPoToken, sessionPoToken }
|
||||
return await integrityTokenBasedMinter.mintAsWebsafeString(videoId)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ const IpcChannels = {
|
||||
|
||||
SET_INVIDIOUS_AUTHORIZATION: 'set-invidious-authorization',
|
||||
|
||||
GENERATE_PO_TOKENS: 'generate-po-tokens',
|
||||
GENERATE_PO_TOKEN: 'generate-po-token',
|
||||
|
||||
GET_SCREENSHOT_FALLBACK_FOLDER: 'get-screenshot-fallback-folder',
|
||||
CHOOSE_DEFAULT_FOLDER: 'choose-default-folder',
|
||||
@@ -57,13 +57,13 @@ const DBActions = {
|
||||
UPSERT: 2,
|
||||
DELETE: 3,
|
||||
DELETE_MULTIPLE: 4,
|
||||
DELETE_ALL: 5
|
||||
DELETE_ALL: 5,
|
||||
OVERWRITE: 6
|
||||
},
|
||||
|
||||
HISTORY: {
|
||||
OVERWRITE: 20,
|
||||
UPDATE_WATCH_PROGRESS: 21,
|
||||
UPDATE_PLAYLIST: 22,
|
||||
UPDATE_WATCH_PROGRESS: 20,
|
||||
UPDATE_PLAYLIST: 21,
|
||||
},
|
||||
|
||||
PROFILES: {
|
||||
@@ -97,13 +97,13 @@ const SyncEvents = {
|
||||
UPSERT: 1,
|
||||
DELETE: 2,
|
||||
DELETE_MULTIPLE: 3,
|
||||
DELETE_ALL: 4
|
||||
DELETE_ALL: 4,
|
||||
OVERWRITE: 5,
|
||||
},
|
||||
|
||||
HISTORY: {
|
||||
OVERWRITE: 20,
|
||||
UPDATE_WATCH_PROGRESS: 21,
|
||||
UPDATE_PLAYLIST: 22,
|
||||
UPDATE_WATCH_PROGRESS: 20,
|
||||
UPDATE_PLAYLIST: 21,
|
||||
},
|
||||
|
||||
PROFILES: {
|
||||
@@ -152,8 +152,6 @@ const KeyboardShortcuts = {
|
||||
NEW_WINDOW: 'ctrl+N',
|
||||
MINIMIZE_WINDOW: 'ctrl+M',
|
||||
CLOSE_WINDOW: 'ctrl+W',
|
||||
RESTART_WINDOW: 'ctrl+R',
|
||||
FORCE_RESTART_WINDOW: 'ctrl+shift+R',
|
||||
TOGGLE_DEVTOOLS: 'ctrl+shift+I',
|
||||
FOCUS_SEARCH: 'alt+D',
|
||||
SEARCH_IN_NEW_WINDOW: 'shift+enter',
|
||||
|
||||
@@ -272,6 +272,12 @@ class SearchHistory {
|
||||
return db.searchHistory.updateAsync({ _id: searchHistoryEntry._id }, searchHistoryEntry, { upsert: true })
|
||||
}
|
||||
|
||||
static async overwrite(records) {
|
||||
await db.searchHistory.removeAsync({}, { multi: true })
|
||||
|
||||
await db.searchHistory.insertAsync(records)
|
||||
}
|
||||
|
||||
static delete(_id) {
|
||||
return db.searchHistory.removeAsync({ _id: _id })
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ class History {
|
||||
}
|
||||
|
||||
static overwrite(records) {
|
||||
return window.ftElectron.dbHistory(DBActions.HISTORY.OVERWRITE, records)
|
||||
return window.ftElectron.dbHistory(DBActions.GENERAL.OVERWRITE, records)
|
||||
}
|
||||
|
||||
static updateWatchProgress(videoId, watchProgress) {
|
||||
@@ -139,6 +139,10 @@ class SearchHistory {
|
||||
return window.ftElectron.dbSearchHistory(DBActions.GENERAL.UPSERT, searchHistoryEntry)
|
||||
}
|
||||
|
||||
static overwrite(records) {
|
||||
return window.ftElectron.dbSearchHistory(DBActions.GENERAL.OVERWRITE, records)
|
||||
}
|
||||
|
||||
static delete(_id) {
|
||||
return window.ftElectron.dbSearchHistory(DBActions.GENERAL.DELETE, _id)
|
||||
}
|
||||
|
||||
@@ -22,9 +22,21 @@ if (process.env.IS_ELECTRON_MAIN) {
|
||||
dbPath = (dbName) => `${dbName}.db`
|
||||
}
|
||||
|
||||
export const settings = new Datastore({ filename: dbPath('settings'), autoload: !process.env.IS_ELECTRON_MAIN })
|
||||
export const profiles = new Datastore({ filename: dbPath('profiles'), autoload: !process.env.IS_ELECTRON_MAIN })
|
||||
export const playlists = new Datastore({ filename: dbPath('playlists'), autoload: !process.env.IS_ELECTRON_MAIN })
|
||||
export const history = new Datastore({ filename: dbPath('history'), autoload: !process.env.IS_ELECTRON_MAIN })
|
||||
export const searchHistory = new Datastore({ filename: dbPath('search-history'), autoload: !process.env.IS_ELECTRON_MAIN })
|
||||
export const subscriptionCache = new Datastore({ filename: dbPath('subscription-cache'), autoload: !process.env.IS_ELECTRON_MAIN })
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
function createDatastore(name) {
|
||||
return new Datastore({
|
||||
filename: dbPath(name),
|
||||
autoload: !process.env.IS_ELECTRON_MAIN,
|
||||
// Automatically clean up corrupted data, instead of crashing
|
||||
corruptAlertThreshold: 1
|
||||
})
|
||||
}
|
||||
|
||||
export const settings = createDatastore('settings')
|
||||
export const profiles = createDatastore('profiles')
|
||||
export const playlists = createDatastore('playlists')
|
||||
export const history = createDatastore('history')
|
||||
export const searchHistory = createDatastore('search-history')
|
||||
export const subscriptionCache = createDatastore('subscription-cache')
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<% if (process.env.SUPPORTS_LOCAL_API) { %>
|
||||
<% if (process.env.IS_ELECTRON) { %>
|
||||
<iframe
|
||||
id="sigFrame"
|
||||
src="<%= sigFrameSrc %>"
|
||||
@@ -24,8 +24,7 @@
|
||||
style="display: none; pointer-events: none"
|
||||
tabindex="-1"
|
||||
></iframe>
|
||||
<% } %>
|
||||
<% if (!process.env.IS_ELECTRON) { %>
|
||||
<% } else { %>
|
||||
<script>
|
||||
// This is the service worker with the Advanced caching
|
||||
|
||||
|
||||
@@ -351,9 +351,19 @@ function runApp() {
|
||||
replaceMainWindow: false,
|
||||
showWindowNow: true,
|
||||
})
|
||||
ipcMain.once(IpcChannels.APP_READY, () => {
|
||||
newWindow.webContents.send(IpcChannels.OPEN_URL, newStartupUrl)
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {import('electron').IpcMainEvent} event
|
||||
*/
|
||||
const readyHandler = (event) => {
|
||||
if (isFreeTubeUrl(event.senderFrame.url)) {
|
||||
newWindow.webContents.ipc.off(IpcChannels.APP_READY, readyHandler)
|
||||
|
||||
event.reply(IpcChannels.OPEN_URL, newStartupUrl)
|
||||
}
|
||||
}
|
||||
|
||||
newWindow.webContents.ipc.on(IpcChannels.APP_READY, readyHandler)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -425,23 +435,43 @@ function runApp() {
|
||||
// FreeTube needs the following permissions:
|
||||
// - "fullscreen": So that the video player can enter full screen
|
||||
// - "clipboard-sanitized-write": To allow the user to copy video URLs and error messages
|
||||
// - "fileSystem" Needed for the Web File System API (e.g. importing and exporting data)
|
||||
|
||||
session.defaultSession.setPermissionCheckHandler((webContents, permission, requestingOrigin) => {
|
||||
session.defaultSession.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => {
|
||||
if (!isFreeTubeUrl(requestingOrigin)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return permission === 'fullscreen' || permission === 'clipboard-sanitized-write'
|
||||
return (
|
||||
permission === 'fullscreen' ||
|
||||
permission === 'clipboard-sanitized-write' ||
|
||||
(permission === 'fileSystem' && !details.isDirectory)
|
||||
)
|
||||
})
|
||||
|
||||
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
|
||||
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
|
||||
if (!isFreeTubeUrl(webContents.getURL())) {
|
||||
// eslint-disable-next-line n/no-callback-literal
|
||||
callback(false)
|
||||
return
|
||||
}
|
||||
|
||||
callback(permission === 'fullscreen' || permission === 'clipboard-sanitized-write')
|
||||
callback(
|
||||
permission === 'fullscreen' ||
|
||||
permission === 'clipboard-sanitized-write' ||
|
||||
(permission === 'fileSystem' && !details.isDirectory)
|
||||
)
|
||||
})
|
||||
|
||||
session.defaultSession.on('file-system-access-restricted', (event, details, callback) => {
|
||||
if (!isFreeTubeUrl(details.origin)) {
|
||||
// eslint-disable-next-line n/no-callback-literal
|
||||
callback('deny')
|
||||
return
|
||||
}
|
||||
|
||||
// eslint-disable-next-line n/no-callback-literal
|
||||
callback(details.isDirectory ? 'deny' : 'allow')
|
||||
})
|
||||
|
||||
let docArray
|
||||
@@ -690,14 +720,6 @@ function runApp() {
|
||||
|
||||
await createWindow()
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
try {
|
||||
require('vue-devtools').install()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
if (isDebug) {
|
||||
mainWindow.webContents.openDevTools()
|
||||
}
|
||||
@@ -726,6 +748,8 @@ function runApp() {
|
||||
window.show()
|
||||
}
|
||||
}
|
||||
|
||||
if (trayWindows.length === BrowserWindow.getAllWindows().length) { mainWindow = window }
|
||||
} else if (trayWindows.length > 0) {
|
||||
window.close()
|
||||
}
|
||||
@@ -761,16 +785,29 @@ function runApp() {
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Quit',
|
||||
click: handleQuit
|
||||
}
|
||||
...defaultTrayMenu()
|
||||
)
|
||||
|
||||
const menu = Menu.buildFromTemplate(menuItems)
|
||||
tray.setContextMenu(menu)
|
||||
}
|
||||
|
||||
function defaultTrayMenu() {
|
||||
return [
|
||||
{
|
||||
label: 'New Window',
|
||||
click: () => createWindow({
|
||||
showWindowNow: true,
|
||||
replaceMainWindow: trayWindows.some(item => item.id === mainWindow.id)
|
||||
})
|
||||
},
|
||||
{
|
||||
label: 'Quit',
|
||||
click: handleQuit
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function destroyTray() {
|
||||
if (!tray) return
|
||||
|
||||
@@ -778,11 +815,7 @@ function runApp() {
|
||||
tray.destroy()
|
||||
tray = null
|
||||
} else {
|
||||
const quitItem = [{
|
||||
label: 'Quit',
|
||||
click: handleQuit
|
||||
}]
|
||||
const menu = Menu.buildFromTemplate(quitItem)
|
||||
const menu = Menu.buildFromTemplate(defaultTrayMenu())
|
||||
tray.setContextMenu(menu)
|
||||
}
|
||||
}
|
||||
@@ -839,7 +872,7 @@ function runApp() {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return url_ !== null && url_.protocol === 'http:' && url_.host === 'localhost:9080' && (url_.pathname === '/' || url_.pathname === '/index.html')
|
||||
} else {
|
||||
return url_ !== null && url_.protocol === 'app:' && url_.host === 'bundle' && url_.pathname === '/index.html'
|
||||
return url_ !== null && url_.protocol === 'app:' && url_.host === 'bundle' && (url_.pathname === '/' || url_.pathname === '/index.html')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -897,6 +930,8 @@ function runApp() {
|
||||
return '#fdf6e3'
|
||||
case 'everforest-light-low':
|
||||
return '#f3ead3'
|
||||
case 'catppuccin-latte':
|
||||
return '#eff1f5'
|
||||
case 'system':
|
||||
default:
|
||||
return nativeTheme.shouldUseDarkColors ? '#212121' : '#f1f1f1'
|
||||
@@ -907,10 +942,27 @@ function runApp() {
|
||||
return nativeTheme.shouldUseDarkColors ? '#212121' : '#f1f1f1'
|
||||
})
|
||||
|
||||
/**
|
||||
* Initial window options
|
||||
*/
|
||||
const commonBrowserWindowOptions = {
|
||||
let savedBounds, savedMaximized, savedFullScreen
|
||||
|
||||
const boundsDoc = await baseHandlers.settings._findOne('bounds')
|
||||
if (typeof boundsDoc?.value === 'object') {
|
||||
const { maximized, fullScreen, ...bounds } = boundsDoc.value
|
||||
const windowVisible = screen.getAllDisplays().some(display => {
|
||||
const { x, y, width, height } = display.bounds
|
||||
return !(bounds.x > x + width || bounds.x + bounds.width < x || bounds.y > y + height || bounds.y + bounds.height < y)
|
||||
})
|
||||
|
||||
if (windowVisible) {
|
||||
savedBounds = bounds
|
||||
}
|
||||
|
||||
savedMaximized = maximized
|
||||
savedFullScreen = fullScreen
|
||||
}
|
||||
|
||||
const newWindow = new BrowserWindow({
|
||||
// It will be shown later when ready via `ready-to-show` event
|
||||
show: showWindowNow,
|
||||
backgroundColor: windowBackground,
|
||||
darkTheme: nativeTheme.shouldUseDarkColors,
|
||||
icon: process.env.NODE_ENV === 'development'
|
||||
@@ -926,18 +978,19 @@ function runApp() {
|
||||
: path.resolve(__dirname, 'preload.js')
|
||||
},
|
||||
minWidth: 340,
|
||||
minHeight: 380
|
||||
}
|
||||
|
||||
const newWindow = new BrowserWindow(
|
||||
Object.assign(
|
||||
{
|
||||
// It will be shown later when ready via `ready-to-show` event
|
||||
show: showWindowNow
|
||||
},
|
||||
commonBrowserWindowOptions
|
||||
)
|
||||
)
|
||||
minHeight: 380,
|
||||
...savedBounds
|
||||
? {
|
||||
x: savedBounds.x,
|
||||
y: savedBounds.y,
|
||||
width: savedBounds.width,
|
||||
height: savedBounds.height
|
||||
}
|
||||
: {
|
||||
width: 1200,
|
||||
height: 800
|
||||
}
|
||||
})
|
||||
|
||||
// region Ensure child windows use same options since electron 14
|
||||
|
||||
@@ -1008,6 +1061,14 @@ function runApp() {
|
||||
if (trayOnMinimize) {
|
||||
newWindow.hide()
|
||||
manageTray(newWindow)
|
||||
|
||||
if (newWindow === mainWindow) {
|
||||
// A timer is needed because getFocusedWindow doesn't update until the minimize event ends
|
||||
setTimeout(() => {
|
||||
const newMainWindow = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows().find(window => window.isVisible())
|
||||
if (newMainWindow) { mainWindow = newMainWindow }
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1024,35 +1085,12 @@ function runApp() {
|
||||
mainWindow = newWindow
|
||||
}
|
||||
|
||||
newWindow.setBounds({
|
||||
width: 1200,
|
||||
height: 800
|
||||
})
|
||||
if (savedMaximized) {
|
||||
newWindow.maximize()
|
||||
}
|
||||
|
||||
const boundsDoc = await baseHandlers.settings._findOne('bounds')
|
||||
if (typeof boundsDoc?.value === 'object') {
|
||||
const { maximized, fullScreen, ...bounds } = boundsDoc.value
|
||||
const windowVisible = screen.getAllDisplays().some(display => {
|
||||
const { x, y, width, height } = display.bounds
|
||||
return !(bounds.x > x + width || bounds.x + bounds.width < x || bounds.y > y + height || bounds.y + bounds.height < y)
|
||||
})
|
||||
|
||||
if (windowVisible) {
|
||||
newWindow.setBounds({
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height
|
||||
})
|
||||
}
|
||||
|
||||
if (maximized) {
|
||||
newWindow.maximize()
|
||||
}
|
||||
|
||||
if (fullScreen) {
|
||||
newWindow.setFullScreen(true)
|
||||
}
|
||||
if (savedFullScreen) {
|
||||
newWindow.setFullScreen(true)
|
||||
}
|
||||
|
||||
// If called multiple times
|
||||
@@ -1069,13 +1107,21 @@ function runApp() {
|
||||
}
|
||||
|
||||
if (typeof searchQueryText === 'string' && searchQueryText.length > 0) {
|
||||
ipcMain.once(IpcChannels.SEARCH_INPUT_HANDLING_READY, () => {
|
||||
newWindow.webContents.send(IpcChannels.UPDATE_SEARCH_INPUT_TEXT, searchQueryText)
|
||||
})
|
||||
/**
|
||||
* @param {import('electron').IpcMainEvent} event
|
||||
*/
|
||||
const searchInputReadyHandler = (event) => {
|
||||
if (isFreeTubeUrl(event.senderFrame.url)) {
|
||||
newWindow.webContents.ipc.off(IpcChannels.SEARCH_INPUT_HANDLING_READY, searchInputReadyHandler)
|
||||
|
||||
event.reply(IpcChannels.UPDATE_SEARCH_INPUT_TEXT, searchQueryText)
|
||||
}
|
||||
}
|
||||
|
||||
newWindow.webContents.ipc.on(IpcChannels.SEARCH_INPUT_HANDLING_READY, searchInputReadyHandler)
|
||||
}
|
||||
|
||||
// Show when loaded
|
||||
newWindow.once('ready-to-show', () => {
|
||||
const showWindow = () => {
|
||||
if (newWindow.isVisible()) {
|
||||
// only open the dev tools if they aren't already open
|
||||
if (process.env.NODE_ENV === 'development' && !newWindow.webContents.isDevToolsOpened()) {
|
||||
@@ -1094,7 +1140,18 @@ function runApp() {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
newWindow.webContents.openDevTools({ activate: false })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The `ready-to-show` event doesn't always fire on wayland.
|
||||
// Use the `did-finish-load` event on the web contents instead as that is similar enough
|
||||
// https://github.com/electron/electron/issues/48859
|
||||
|
||||
if (process.platform === 'linux' && app.commandLine.getSwitchValue('ozone-platform') === 'wayland') {
|
||||
newWindow.webContents.once('did-finish-load', showWindow)
|
||||
} else {
|
||||
// Show when loaded
|
||||
newWindow.once('ready-to-show', showWindow)
|
||||
}
|
||||
|
||||
newWindow.once('close', async () => {
|
||||
if (BrowserWindow.getAllWindows().length !== 1) {
|
||||
@@ -1124,11 +1181,13 @@ function runApp() {
|
||||
return newWindow
|
||||
}
|
||||
|
||||
ipcMain.on(IpcChannels.APP_READY, () => {
|
||||
if (startupUrl) {
|
||||
mainWindow.webContents.send(IpcChannels.OPEN_URL, startupUrl)
|
||||
ipcMain.on(IpcChannels.APP_READY, (event) => {
|
||||
if (isFreeTubeUrl(event.senderFrame.url)) {
|
||||
if (startupUrl) {
|
||||
mainWindow.webContents.send(IpcChannels.OPEN_URL, startupUrl)
|
||||
}
|
||||
startupUrl = null
|
||||
}
|
||||
startupUrl = null
|
||||
})
|
||||
|
||||
function relaunch() {
|
||||
@@ -1172,15 +1231,23 @@ function runApp() {
|
||||
const allWindows = BrowserWindow.getAllWindows()
|
||||
|
||||
allWindows.forEach((window) => {
|
||||
window.webContents.send(IpcChannels.NATIVE_THEME_UPDATE, nativeTheme.shouldUseDarkColors)
|
||||
if (isFreeTubeUrl(window.webContents.getURL())) {
|
||||
window.webContents.send(IpcChannels.NATIVE_THEME_UPDATE, nativeTheme.shouldUseDarkColors)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannels.GENERATE_PO_TOKENS, (_, videoId, visitorData, context) => {
|
||||
return generatePoToken(videoId, visitorData, context, proxyUrl)
|
||||
ipcMain.handle(IpcChannels.GENERATE_PO_TOKEN, (event, videoId, context) => {
|
||||
if (isFreeTubeUrl(event.senderFrame.url)) {
|
||||
return generatePoToken(videoId, context, proxyUrl)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.on(IpcChannels.ENABLE_PROXY, (_, url) => {
|
||||
ipcMain.on(IpcChannels.ENABLE_PROXY, (event, url) => {
|
||||
if (!isFreeTubeUrl(event.senderFrame.url)) {
|
||||
return
|
||||
}
|
||||
|
||||
session.defaultSession.setProxy({
|
||||
proxyRules: url
|
||||
})
|
||||
@@ -1188,7 +1255,11 @@ function runApp() {
|
||||
session.defaultSession.closeAllConnections()
|
||||
})
|
||||
|
||||
ipcMain.on(IpcChannels.DISABLE_PROXY, () => {
|
||||
ipcMain.on(IpcChannels.DISABLE_PROXY, (event) => {
|
||||
if (!isFreeTubeUrl(event.senderFrame.url)) {
|
||||
return
|
||||
}
|
||||
|
||||
session.defaultSession.setProxy({})
|
||||
proxyUrl = undefined
|
||||
session.defaultSession.closeAllConnections()
|
||||
@@ -1200,7 +1271,11 @@ function runApp() {
|
||||
// Math.trunc but with a bitwise OR so that it can be calcuated at build time and the number inlined
|
||||
const HALF_OF_NAV_HISTORY_DISPLAY_LIMIT = (NAV_HISTORY_DISPLAY_LIMIT / 2) | 0
|
||||
|
||||
ipcMain.handle(IpcChannels.GET_NAVIGATION_HISTORY, ({ sender }) => {
|
||||
ipcMain.handle(IpcChannels.GET_NAVIGATION_HISTORY, ({ senderFrame, sender }) => {
|
||||
if (!isFreeTubeUrl(senderFrame.url)) {
|
||||
return
|
||||
}
|
||||
|
||||
const activeIndex = sender.navigationHistory.getActiveIndex()
|
||||
const length = sender.navigationHistory.length()
|
||||
|
||||
@@ -1231,17 +1306,17 @@ function runApp() {
|
||||
|
||||
// #endregion navigation history
|
||||
|
||||
ipcMain.handle(IpcChannels.GET_SYSTEM_LOCALE, () => {
|
||||
// we should switch to getPreferredSystemLanguages at some point and iterate through until we find a supported locale
|
||||
return app.getSystemLocale()
|
||||
ipcMain.handle(IpcChannels.GET_SYSTEM_LOCALE, (event) => {
|
||||
if (isFreeTubeUrl(event.senderFrame.url)) {
|
||||
// we should switch to getPreferredSystemLanguages at some point and iterate through until we find a supported locale
|
||||
return app.getSystemLocale()
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannels.GET_SCREENSHOT_FALLBACK_FOLDER, (event) => {
|
||||
if (!isFreeTubeUrl(event.senderFrame.url)) {
|
||||
return
|
||||
if (isFreeTubeUrl(event.senderFrame.url)) {
|
||||
return path.join(app.getPath('pictures'), 'Freetube')
|
||||
}
|
||||
|
||||
return path.join(app.getPath('pictures'), 'Freetube')
|
||||
})
|
||||
|
||||
ipcMain.on(IpcChannels.CHOOSE_DEFAULT_FOLDER, async (event, kind) => {
|
||||
@@ -1286,7 +1361,9 @@ function runApp() {
|
||||
}
|
||||
|
||||
BrowserWindow.getAllWindows().forEach((window) => {
|
||||
window.webContents.send(IpcChannels.SYNC_SETTINGS, syncPayload)
|
||||
if (isFreeTubeUrl(window.webContents.getURL())) {
|
||||
window.webContents.send(IpcChannels.SYNC_SETTINGS, syncPayload)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1403,16 +1480,24 @@ function runApp() {
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.on(IpcChannels.OPEN_IN_EXTERNAL_PLAYER, (_, executable, args) => {
|
||||
const child = cp.spawn(executable, args, { detached: true, stdio: 'ignore' })
|
||||
child.unref()
|
||||
ipcMain.on(IpcChannels.OPEN_IN_EXTERNAL_PLAYER, (event, executable, args) => {
|
||||
if (isFreeTubeUrl(event.senderFrame.url)) {
|
||||
const child = cp.spawn(executable, args, { detached: true, stdio: 'ignore' })
|
||||
child.unref()
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannels.GET_REPLACE_HTTP_CACHE, () => {
|
||||
return replaceHttpCache
|
||||
ipcMain.handle(IpcChannels.GET_REPLACE_HTTP_CACHE, (event) => {
|
||||
if (isFreeTubeUrl(event.senderFrame.url)) {
|
||||
return replaceHttpCache
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.once(IpcChannels.TOGGLE_REPLACE_HTTP_CACHE, async () => {
|
||||
ipcMain.once(IpcChannels.TOGGLE_REPLACE_HTTP_CACHE, async (event) => {
|
||||
if (!isFreeTubeUrl(event.senderFrame.url)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (replaceHttpCache) {
|
||||
await asyncFs.rm(REPLACE_HTTP_CACHE_PATH)
|
||||
} else {
|
||||
@@ -1433,7 +1518,11 @@ function runApp() {
|
||||
return path.join(PLAYER_CACHE_PATH, sanitizedKey)
|
||||
}
|
||||
|
||||
ipcMain.handle(IpcChannels.PLAYER_CACHE_GET, async (_, key) => {
|
||||
ipcMain.handle(IpcChannels.PLAYER_CACHE_GET, async (event, key) => {
|
||||
if (!isFreeTubeUrl(event.senderFrame.url)) {
|
||||
return
|
||||
}
|
||||
|
||||
const filePath = playerCachePathForKey(key)
|
||||
|
||||
try {
|
||||
@@ -1451,7 +1540,11 @@ function runApp() {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannels.PLAYER_CACHE_SET, async (_, key, value) => {
|
||||
ipcMain.handle(IpcChannels.PLAYER_CACHE_SET, async (event, key, value) => {
|
||||
if (!isFreeTubeUrl(event.senderFrame.url)) {
|
||||
return
|
||||
}
|
||||
|
||||
const filePath = playerCachePathForKey(key)
|
||||
|
||||
await asyncFs.mkdir(PLAYER_CACHE_PATH, { recursive: true })
|
||||
@@ -1480,6 +1573,10 @@ function runApp() {
|
||||
|
||||
// Settings
|
||||
ipcMain.handle(IpcChannels.DB_SETTINGS, async (event, { action, data }) => {
|
||||
if (!isFreeTubeUrl(event.senderFrame.url)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case DBActions.GENERAL.FIND:
|
||||
@@ -1538,6 +1635,10 @@ function runApp() {
|
||||
// *********** //
|
||||
// History
|
||||
ipcMain.handle(IpcChannels.DB_HISTORY, async (event, { action, data }) => {
|
||||
if (!isFreeTubeUrl(event.senderFrame.url)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case DBActions.GENERAL.FIND:
|
||||
@@ -1552,12 +1653,12 @@ function runApp() {
|
||||
)
|
||||
return null
|
||||
|
||||
case DBActions.HISTORY.OVERWRITE:
|
||||
case DBActions.GENERAL.OVERWRITE:
|
||||
await baseHandlers.history.overwrite(data)
|
||||
syncOtherWindows(
|
||||
IpcChannels.SYNC_HISTORY,
|
||||
event,
|
||||
{ event: SyncEvents.HISTORY.OVERWRITE, data }
|
||||
{ event: SyncEvents.GENERAL.OVERWRITE, data }
|
||||
)
|
||||
return null
|
||||
|
||||
@@ -1610,6 +1711,10 @@ function runApp() {
|
||||
// *********** //
|
||||
// Profiles
|
||||
ipcMain.handle(IpcChannels.DB_PROFILES, async (event, { action, data }) => {
|
||||
if (!isFreeTubeUrl(event.senderFrame.url)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case DBActions.GENERAL.CREATE: {
|
||||
@@ -1678,6 +1783,10 @@ function runApp() {
|
||||
// The remaining should have it implemented only when playlists
|
||||
// get fully implemented into the app
|
||||
ipcMain.handle(IpcChannels.DB_PLAYLISTS, async (event, { action, data }) => {
|
||||
if (!isFreeTubeUrl(event.senderFrame.url)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case DBActions.GENERAL.CREATE:
|
||||
@@ -1779,6 +1888,10 @@ function runApp() {
|
||||
// ************** //
|
||||
// Search History
|
||||
ipcMain.handle(IpcChannels.DB_SEARCH_HISTORY, async (event, { action, data }) => {
|
||||
if (!isFreeTubeUrl(event.senderFrame.url)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case DBActions.GENERAL.FIND:
|
||||
@@ -1793,6 +1906,15 @@ function runApp() {
|
||||
)
|
||||
return null
|
||||
|
||||
case DBActions.GENERAL.OVERWRITE:
|
||||
await baseHandlers.searchHistory.overwrite(data)
|
||||
syncOtherWindows(
|
||||
IpcChannels.SYNC_SEARCH_HISTORY,
|
||||
event,
|
||||
{ event: SyncEvents.GENERAL.OVERWRITE, data }
|
||||
)
|
||||
return null
|
||||
|
||||
case DBActions.GENERAL.DELETE:
|
||||
await baseHandlers.searchHistory.delete(data)
|
||||
syncOtherWindows(
|
||||
@@ -1824,6 +1946,10 @@ function runApp() {
|
||||
// *********** //
|
||||
// Profiles
|
||||
ipcMain.handle(IpcChannels.DB_SUBSCRIPTION_CACHE, async (event, { action, data }) => {
|
||||
if (!isFreeTubeUrl(event.senderFrame.url)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case DBActions.GENERAL.FIND:
|
||||
@@ -1906,7 +2032,7 @@ function runApp() {
|
||||
|
||||
function syncOtherWindows(channel, event, payload) {
|
||||
const otherWindows = BrowserWindow.getAllWindows().filter((window) => {
|
||||
return window.webContents.id !== event.sender.id
|
||||
return window.webContents.id !== event.sender.id && isFreeTubeUrl(window.webContents.getURL())
|
||||
})
|
||||
|
||||
for (const window of otherWindows) {
|
||||
@@ -2016,9 +2142,19 @@ function runApp() {
|
||||
replaceMainWindow: false,
|
||||
showWindowNow: true,
|
||||
})
|
||||
ipcMain.once(IpcChannels.APP_READY, () => {
|
||||
newWindow.webContents.send(IpcChannels.OPEN_URL, newStartupUrl)
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {import('electron').IpcMainEvent} event
|
||||
*/
|
||||
const readyHandler = (event) => {
|
||||
if (isFreeTubeUrl(event.senderFrame.url)) {
|
||||
newWindow.webContents.ipc.off(IpcChannels.APP_READY, readyHandler)
|
||||
|
||||
event.reply(IpcChannels.OPEN_URL, newStartupUrl)
|
||||
}
|
||||
}
|
||||
|
||||
newWindow.webContents.ipc.on(IpcChannels.APP_READY, readyHandler)
|
||||
})
|
||||
|
||||
app.on('web-contents-created', (_, webContents) => {
|
||||
@@ -2077,7 +2213,7 @@ function runApp() {
|
||||
*/
|
||||
|
||||
function navigateTo(path, browserWindow) {
|
||||
if (browserWindow == null) {
|
||||
if (browserWindow == null || !isFreeTubeUrl(browserWindow.webContents.getURL())) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2158,11 +2294,6 @@ function runApp() {
|
||||
{
|
||||
label: 'View',
|
||||
submenu: [
|
||||
{ role: 'reload' },
|
||||
{
|
||||
role: 'forcereload',
|
||||
accelerator: 'CmdOrCtrl+Shift+R'
|
||||
},
|
||||
{ role: 'toggledevtools' },
|
||||
{ role: 'toggledevtools', accelerator: 'f12', visible: false },
|
||||
{
|
||||
@@ -2270,7 +2401,7 @@ function runApp() {
|
||||
},
|
||||
type: 'normal'
|
||||
},
|
||||
!hideTrendingVideos && {
|
||||
(!hideTrendingVideos && (backendFallback || backendPreference === 'local')) && {
|
||||
label: 'Trending',
|
||||
click: (_menuItem, browserWindow, _event) => {
|
||||
navigateTo('/trending', browserWindow)
|
||||
|
||||
@@ -3,19 +3,18 @@ import { readFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
|
||||
/**
|
||||
* Generates a poToken (proof of origin token) using `bgutils-js`.
|
||||
* Generates a content-bound poToken (proof of origin token) using `bgutils-js`.
|
||||
* The script to generate it is `src/botGuardScript.js`
|
||||
*
|
||||
* This is intentionally split out into it's own thing, with it's own temporary in-memory session,
|
||||
* as the BotGuard stuff accesses the global `document` and `window` objects and also requires making some requests.
|
||||
* So we definitely don't want it running in the same places as the rest of the FreeTube code with the user data.
|
||||
* @param {string} videoId
|
||||
* @param {string} visitorData
|
||||
* @param {string} context
|
||||
* @param {string|undefined} proxyUrl
|
||||
* @returns {Promise<{ contentPoToken: string, sessionPoToken: string }>}
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function generatePoToken(videoId, visitorData, context, proxyUrl) {
|
||||
export async function generatePoToken(videoId, context, proxyUrl) {
|
||||
const sessionUuid = crypto.randomUUID()
|
||||
|
||||
const theSession = session.fromPartition(`potoken-${sessionUuid}`, { cache: false })
|
||||
@@ -53,15 +52,31 @@ export async function generatePoToken(videoId, visitorData, context, proxyUrl) {
|
||||
callback({ requestHeaders })
|
||||
})
|
||||
|
||||
theSession.webRequest.onHeadersReceived({ urls: ['https://*/*'] }, ({ responseHeaders }, callback) => {
|
||||
if (responseHeaders) {
|
||||
callback({
|
||||
responseHeaders: {
|
||||
...responseHeaders,
|
||||
'Access-Control-Allow-Origin': ['*'],
|
||||
'Access-Control-Allow-Methods': ['GET, HEAD, POST, PUT, DELETE, CONNECT, OPTIONS, TRACE, PATCH']
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
theSession.webRequest.onBeforeRequest({ urls: ['<all_urls>'], types: ['cspReport', 'ping'] }, (details, callback) => {
|
||||
callback({ cancel: true })
|
||||
})
|
||||
|
||||
const webContentsView = new WebContentsView({
|
||||
webPreferences: {
|
||||
backgroundThrottling: false,
|
||||
safeDialogs: true,
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
v8CacheOptions: 'none',
|
||||
session: theSession,
|
||||
offscreen: true,
|
||||
webSecurity: false,
|
||||
disableBlinkFeatures: 'ElectronCSSCornerSmoothing'
|
||||
}
|
||||
})
|
||||
@@ -97,7 +112,7 @@ export async function generatePoToken(videoId, visitorData, context, proxyUrl) {
|
||||
}
|
||||
})
|
||||
|
||||
const script = await getScript(videoId, visitorData, context)
|
||||
const script = await getScript(videoId, context)
|
||||
|
||||
const response = await webContentsView.webContents.executeJavaScript(script)
|
||||
|
||||
@@ -111,10 +126,9 @@ let cachedScript
|
||||
|
||||
/**
|
||||
* @param {string} videoId
|
||||
* @param {string} visitorData
|
||||
* @param {string} context
|
||||
*/
|
||||
async function getScript(videoId, visitorData, context) {
|
||||
async function getScript(videoId, context) {
|
||||
if (!cachedScript) {
|
||||
const pathToScript = process.env.NODE_ENV === 'development'
|
||||
? join(__dirname, '../../dist/botGuardScript.js')
|
||||
@@ -129,5 +143,5 @@ async function getScript(videoId, visitorData, context) {
|
||||
cachedScript = content.replace(match[0], `;${functionName}(FT_PARAMS)`)
|
||||
}
|
||||
|
||||
return cachedScript.replace('FT_PARAMS', `"${videoId}","${visitorData}",${context}`)
|
||||
return cachedScript.replace('FT_PARAMS', `"${videoId}",${context}`)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { IpcChannels } from '../constants.js'
|
||||
* all systems running the electron app.
|
||||
*/
|
||||
ipcRenderer.on(IpcChannels.NATIVE_THEME_UPDATE, (_, shouldUseDarkColors) => {
|
||||
webFrame.executeJavaScript(`document.body.dataset.systemTheme = "${shouldUseDarkColors ? 'dark' : 'light'}"`).catch()
|
||||
document.body.dataset.systemTheme = shouldUseDarkColors ? 'dark' : 'light'
|
||||
})
|
||||
|
||||
let currentUpdateSearchInputTextListener
|
||||
@@ -100,12 +100,11 @@ export default {
|
||||
|
||||
/**
|
||||
* @param {string} videoId
|
||||
* @param {string} visitorData
|
||||
* @param {string} context
|
||||
* @returns {Promise<{ contentPoToken: string, sessionPoToken: string }>}
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
generatePoTokens: (videoId, visitorData, context) => {
|
||||
return ipcRenderer.invoke(IpcChannels.GENERATE_PO_TOKENS, videoId, visitorData, context)
|
||||
generatePoToken: (videoId, context) => {
|
||||
return ipcRenderer.invoke(IpcChannels.GENERATE_PO_TOKEN, videoId, context)
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -56,8 +56,8 @@
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.fade-enter,
|
||||
.fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ export default defineComponent({
|
||||
latestBlogUrl: '',
|
||||
updateChangelog: '',
|
||||
changeLogTitle: '',
|
||||
isPromptOpen: false,
|
||||
lastExternalLinkToBeOpened: '',
|
||||
showExternalLinkOpeningPrompt: false,
|
||||
externalLinkOpeningPromptValues: [
|
||||
@@ -154,6 +153,10 @@ export default defineComponent({
|
||||
appTitle: function () {
|
||||
return this.$store.getters.getAppTitle
|
||||
},
|
||||
|
||||
isAnyPromptOpen: function () {
|
||||
return this.$store.getters.isAnyPromptOpen
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
windowTitle: 'setWindowTitle',
|
||||
@@ -209,18 +212,16 @@ export default defineComponent({
|
||||
}, 500)
|
||||
})
|
||||
|
||||
this.$router.onReady(() => {
|
||||
if (this.$router.currentRoute.path === '/') {
|
||||
this.$router.replace({ path: this.landingPage })
|
||||
}
|
||||
if (this.$route.path === '/') {
|
||||
this.$router.replace({ path: this.landingPage })
|
||||
}
|
||||
|
||||
this.setWindowTitle()
|
||||
})
|
||||
this.setWindowTitle()
|
||||
})
|
||||
|
||||
document.addEventListener('dragstart', this.handleDragStart)
|
||||
},
|
||||
beforeDestroy: function () {
|
||||
beforeUnmount: function () {
|
||||
document.removeEventListener('dragstart', this.handleDragStart)
|
||||
},
|
||||
methods: {
|
||||
@@ -332,10 +333,6 @@ export default defineComponent({
|
||||
this.showBlogBanner = false
|
||||
},
|
||||
|
||||
handlePromptPortalUpdate: function(newVal) {
|
||||
this.isPromptOpen = newVal
|
||||
},
|
||||
|
||||
openDownloadsPage: function () {
|
||||
const url = 'https://freetubeapp.io#download'
|
||||
openExternalLink(url)
|
||||
@@ -631,7 +628,6 @@ export default defineComponent({
|
||||
transformed = true
|
||||
break
|
||||
case 'subscriptions':
|
||||
case 'trending':
|
||||
case 'history':
|
||||
transformedURL.pathname = `/feed/${pathParts[1]}`
|
||||
transformed = true
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="dataReady"
|
||||
id="app"
|
||||
class="app"
|
||||
:class="{
|
||||
hideOutlines: outlinesHidden,
|
||||
@@ -10,11 +9,6 @@
|
||||
hideLabelsSideBar: hideLabelsSideBar && !isSideNavOpen
|
||||
}"
|
||||
>
|
||||
<portal-target
|
||||
name="promptPortal"
|
||||
multiple
|
||||
@change="handlePromptPortalUpdate"
|
||||
/>
|
||||
<ft-prompt
|
||||
v-if="showReleaseNotes"
|
||||
theme="readable-width"
|
||||
@@ -71,16 +65,16 @@
|
||||
v-if="showProgressBar"
|
||||
/>
|
||||
<top-nav
|
||||
:inert="isPromptOpen"
|
||||
:inert="isAnyPromptOpen"
|
||||
/>
|
||||
<side-nav
|
||||
ref="sideNav"
|
||||
:inert="isPromptOpen"
|
||||
:inert="isAnyPromptOpen"
|
||||
/>
|
||||
<ft-flex-box
|
||||
class="flexBox routerView"
|
||||
role="main"
|
||||
:inert="isPromptOpen"
|
||||
:inert="isAnyPromptOpen"
|
||||
>
|
||||
<div
|
||||
v-if="showUpdatesBanner || showBlogBanner"
|
||||
@@ -101,17 +95,18 @@
|
||||
@click="handleNewBlogBannerClick"
|
||||
/>
|
||||
</div>
|
||||
<transition
|
||||
<RouterView
|
||||
v-if="dataReady"
|
||||
mode="out-in"
|
||||
name="fade"
|
||||
v-slot="{ Component }"
|
||||
class="routerView"
|
||||
>
|
||||
<!-- <keep-alive> -->
|
||||
<RouterView
|
||||
class="routerView"
|
||||
/>
|
||||
<!-- </keep-alive> -->
|
||||
</transition>
|
||||
<Transition
|
||||
mode="out-in"
|
||||
name="fade"
|
||||
>
|
||||
<component :is="Component" />
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</ft-flex-box>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
v-if="subCount !== null && !hideChannelSubscriptions"
|
||||
class="subCount"
|
||||
>
|
||||
{{ $tc('Global.Counts.Subscriber Count', subCount, { count: formattedSubCount }) }}
|
||||
{{ $t('Global.Counts.Subscriber Count', { count: formattedSubCount }, subCount) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -81,14 +81,14 @@
|
||||
class="tab"
|
||||
:class="{ selectedTab: currentTab === 'home' }"
|
||||
role="tab"
|
||||
:aria-selected="String(currentTab === 'home')"
|
||||
:aria-selected="currentTab === 'home'"
|
||||
aria-controls="homePanel"
|
||||
:tabindex="(currentTab === 'home' || currentTab === 'search') ? 0 : -1"
|
||||
@click="changeTab('home')"
|
||||
@keydown.left.right="focusTab('home', $event)"
|
||||
@keydown.enter.space.prevent="changeTab('home')"
|
||||
>
|
||||
{{ $t("Channel.Home.Home").toUpperCase() }}
|
||||
{{ $t("Channel.Home.Home") }}
|
||||
</div>
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
|
||||
<div
|
||||
@@ -97,14 +97,14 @@
|
||||
class="tab"
|
||||
:class="{ selectedTab: currentTab === 'videos' }"
|
||||
role="tab"
|
||||
:aria-selected="String(currentTab === 'videos')"
|
||||
:aria-selected="currentTab === 'videos'"
|
||||
aria-controls="videoPanel"
|
||||
:tabindex="(currentTab === 'videos' || currentTab === 'search') ? 0 : -1"
|
||||
@click="changeTab('videos')"
|
||||
@keydown.left.right="focusTab('videos', $event)"
|
||||
@keydown.enter.space.prevent="changeTab('videos')"
|
||||
>
|
||||
{{ $t("Channel.Videos.Videos").toUpperCase() }}
|
||||
{{ $t("Channel.Videos.Videos") }}
|
||||
</div>
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
|
||||
<div
|
||||
@@ -113,14 +113,14 @@
|
||||
class="tab"
|
||||
:class="{ selectedTab: currentTab === 'shorts' }"
|
||||
role="tab"
|
||||
:aria-selected="String(currentTab === 'shorts')"
|
||||
:aria-selected="currentTab === 'shorts'"
|
||||
aria-controls="shortPanel"
|
||||
:tabindex="currentTab === 'shorts' ? 0 : -1"
|
||||
@click="changeTab('shorts')"
|
||||
@keydown.left.right="focusTab('shorts', $event)"
|
||||
@keydown.enter.space.prevent="changeTab('shorts')"
|
||||
>
|
||||
{{ $t("Global.Shorts").toUpperCase() }}
|
||||
{{ $t("Global.Shorts") }}
|
||||
</div>
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
|
||||
<div
|
||||
@@ -129,14 +129,14 @@
|
||||
class="tab"
|
||||
:class="{ selectedTab: currentTab === 'live' }"
|
||||
role="tab"
|
||||
:aria-selected="String(currentTab === 'live')"
|
||||
:aria-selected="currentTab === 'live'"
|
||||
aria-controls="livePanel"
|
||||
:tabindex="currentTab === 'live' ? 0 : -1"
|
||||
@click="changeTab('live')"
|
||||
@keydown.left.right="focusTab('live', $event)"
|
||||
@keydown.enter.space.prevent="changeTab('live')"
|
||||
>
|
||||
{{ $t("Channel.Live.Live").toUpperCase() }}
|
||||
{{ $t("Channel.Live.Live") }}
|
||||
</div>
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
|
||||
<div
|
||||
@@ -144,7 +144,7 @@
|
||||
id="releasesTab"
|
||||
class="tab"
|
||||
role="tab"
|
||||
:aria-selected="String(currentTab === 'releases')"
|
||||
:aria-selected="currentTab === 'releases'"
|
||||
aria-controls="releasePanel"
|
||||
:tabindex="currentTab === 'releases' ? 0 : -1"
|
||||
:class="{ selectedTab: currentTab === 'releases' }"
|
||||
@@ -152,7 +152,7 @@
|
||||
@keydown.left.right="focusTab('releases', $event)"
|
||||
@keydown.enter.space.prevent="changeTab('releases')"
|
||||
>
|
||||
{{ $t("Channel.Releases.Releases").toUpperCase() }}
|
||||
{{ $t("Channel.Releases.Releases") }}
|
||||
</div>
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
|
||||
<div
|
||||
@@ -160,7 +160,7 @@
|
||||
id="podcastsTab"
|
||||
class="tab"
|
||||
role="tab"
|
||||
:aria-selected="String(currentTab === 'podcasts')"
|
||||
:aria-selected="currentTab === 'podcasts'"
|
||||
aria-controls="podcastPanel"
|
||||
:tabindex="currentTab === 'podcasts' ? 0 : -1"
|
||||
:class="{ selectedTab: currentTab === 'podcasts' }"
|
||||
@@ -168,7 +168,7 @@
|
||||
@keydown.left.right="focusTab('podcasts', $event)"
|
||||
@keydown.enter.space.prevent="changeTab('podcasts')"
|
||||
>
|
||||
{{ $t("Channel.Podcasts.Podcasts").toUpperCase() }}
|
||||
{{ $t("Channel.Podcasts.Podcasts") }}
|
||||
</div>
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
|
||||
<div
|
||||
@@ -176,7 +176,7 @@
|
||||
id="coursesTab"
|
||||
class="tab"
|
||||
role="tab"
|
||||
:aria-selected="String(currentTab === 'courses')"
|
||||
:aria-selected="currentTab === 'courses'"
|
||||
aria-controls="coursesPanel"
|
||||
:tabindex="currentTab === 'courses' ? 0 : -1"
|
||||
:class="{ selectedTab: currentTab === 'courses' }"
|
||||
@@ -184,7 +184,7 @@
|
||||
@keydown.left.right="focusTab('courses', $event)"
|
||||
@keydown.enter.space.prevent="changeTab('courses')"
|
||||
>
|
||||
{{ $t("Channel.Courses.Courses").toUpperCase() }}
|
||||
{{ $t("Channel.Courses.Courses") }}
|
||||
</div>
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
|
||||
<div
|
||||
@@ -192,7 +192,7 @@
|
||||
id="playlistsTab"
|
||||
class="tab"
|
||||
role="tab"
|
||||
:aria-selected="String(currentTab === 'playlists')"
|
||||
:aria-selected="currentTab === 'playlists'"
|
||||
aria-controls="playlistPanel"
|
||||
:tabindex="currentTab === 'playlists' ? 0 : -1"
|
||||
:class="{ selectedTab: currentTab === 'playlists' }"
|
||||
@@ -200,7 +200,7 @@
|
||||
@keydown.left.right="focusTab('playlists', $event)"
|
||||
@keydown.enter.space.prevent="changeTab('playlists')"
|
||||
>
|
||||
{{ $t("Channel.Playlists.Playlists").toUpperCase() }}
|
||||
{{ $t("Channel.Playlists.Playlists") }}
|
||||
</div>
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
|
||||
<div
|
||||
@@ -208,7 +208,7 @@
|
||||
id="communityTab"
|
||||
class="tab"
|
||||
role="tab"
|
||||
:aria-selected="String(currentTab === 'community')"
|
||||
:aria-selected="currentTab === 'community'"
|
||||
aria-controls="communityPanel"
|
||||
:tabindex="currentTab === 'community' ? 0 : -1"
|
||||
:class="{ selectedTab: currentTab === 'community' }"
|
||||
@@ -216,14 +216,14 @@
|
||||
@keydown.left.right="focusTab('community', $event)"
|
||||
@keydown.enter.space.prevent="changeTab('community')"
|
||||
>
|
||||
{{ $t("Global.Posts").toUpperCase() }}
|
||||
{{ $t("Global.Posts") }}
|
||||
</div>
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
|
||||
<div
|
||||
id="aboutTab"
|
||||
class="tab"
|
||||
role="tab"
|
||||
:aria-selected="String(currentTab === 'about')"
|
||||
:aria-selected="currentTab === 'about'"
|
||||
aria-controls="aboutPanel"
|
||||
:tabindex="currentTab === 'about' ? 0 : -1"
|
||||
:class="{ selectedTab: currentTab === 'about' }"
|
||||
@@ -231,7 +231,7 @@
|
||||
@keydown.left.right="focusTab('about', $event)"
|
||||
@keydown.enter.space.prevent="changeTab('about')"
|
||||
>
|
||||
{{ $t("Channel.About.About").toUpperCase() }}
|
||||
{{ $t("Channel.About.About") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -251,14 +251,14 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, useTemplateRef } from 'vue'
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import FtCard from '../ft-card/ft-card.vue'
|
||||
import FtFlexBox from '../ft-flex-box/ft-flex-box.vue'
|
||||
import FtShareButton from '../FtShareButton/FtShareButton.vue'
|
||||
import FtSubscribeButton from '../FtSubscribeButton/FtSubscribeButton.vue'
|
||||
import FtInput from '../ft-input/ft-input.vue'
|
||||
import FtInput from '../FtInput/FtInput.vue'
|
||||
|
||||
import store from '../../store/index'
|
||||
|
||||
@@ -381,7 +381,7 @@ function search(query) {
|
||||
emit('search', query)
|
||||
}
|
||||
|
||||
const searchBar = ref(null)
|
||||
const searchBar = useTemplateRef('searchBar')
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} event
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
.shelfContainer {
|
||||
max-inline-size: 85vw;
|
||||
}
|
||||
|
||||
.shelfTitle {
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
|
||||
/* Prevents overflow for long values */
|
||||
max-inline-size: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.shelfTitle::marker {
|
||||
@@ -32,4 +40,8 @@
|
||||
.shelfSubtitle {
|
||||
font-style: italic;
|
||||
color: var(--tertiary-text-color);
|
||||
|
||||
/* Prevents overflow for long values */
|
||||
max-inline-size: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<div
|
||||
v-for="(shelf, index) in filteredShelves"
|
||||
:key="index"
|
||||
class="shelfContainer"
|
||||
>
|
||||
<details
|
||||
open
|
||||
|
||||
@@ -65,7 +65,6 @@
|
||||
block-size: 60px;
|
||||
font-size: 20px;
|
||||
line-height: 1em;
|
||||
text-transform: capitalize;
|
||||
color: rgb(0 0 0);
|
||||
background-color: rgb(235 160 172);
|
||||
border-radius: 50%;
|
||||
|
||||
@@ -160,13 +160,9 @@
|
||||
@keydown.space.prevent="toggleCommentReplies(index)"
|
||||
@keydown.enter.prevent="toggleCommentReplies(index)"
|
||||
>
|
||||
<span v-if="!comment.showReplies">{{ $t("Comments.View") }}</span>
|
||||
<span v-else>{{ $t("Comments.Hide") }}</span>
|
||||
{{ comment.numReplies }}
|
||||
<span v-if="comment.numReplies === 1">{{ $t("Comments.Reply").toLowerCase() }}</span>
|
||||
<span v-else>{{ $t("Comments.Replies").toLowerCase() }}</span>
|
||||
<span v-if="comment.hasOwnerReplied && !comment.showReplies"> {{ $t("Comments.From {channelName}", { channelName }) }}</span>
|
||||
<span v-if="comment.numReplies > 1 && comment.hasOwnerReplied && !comment.showReplies"> {{ $t("Comments.And others") }}</span>
|
||||
<span>
|
||||
{{ toggleCommentRepliesLinkText(comment) }}
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
<div
|
||||
@@ -264,7 +260,7 @@
|
||||
v-if="reply.numReplies > 0"
|
||||
class="commentMoreReplies"
|
||||
>
|
||||
{{ $t('Comments.View {replyCount} replies', { replyCount: reply.numReplies }) }}
|
||||
{{ $t('Comments.View {replyCount} replies', { replyCount: reply.numReplies }, reply.numReplies) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
@@ -523,6 +519,26 @@ function getMoreComments() {
|
||||
}
|
||||
}
|
||||
|
||||
/** @typedef {import('../../helpers/api/local').LocalComment | import('../../helpers/api/invidious').InvidiousComment} Comment */
|
||||
/**
|
||||
* @param {Comment} comment
|
||||
*/
|
||||
function toggleCommentRepliesLinkText(comment) {
|
||||
if (comment.showReplies) {
|
||||
return t('Comments.Hide {replyCount} replies', { replyCount: comment.numReplies }, comment.numReplies)
|
||||
}
|
||||
|
||||
if (comment.hasOwnerReplied) {
|
||||
if (comment.numReplies > 1) {
|
||||
return t('Comments.View {replyCount} replies from {channelName} and others', { replyCount: comment.numReplies, channelName: props.channelName })
|
||||
}
|
||||
|
||||
return t('Comments.View 1 reply from {channelName}', { channelName: props.channelName })
|
||||
}
|
||||
|
||||
return t('Comments.View {replyCount} replies', { replyCount: comment.numReplies }, comment.numReplies)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} index
|
||||
*/
|
||||
|
||||
3
src/renderer/components/DataSettings/DataSettings.css
Normal file
3
src/renderer/components/DataSettings/DataSettings.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.box {
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
<h4 class="groupTitle">
|
||||
{{ $t('Subscriptions.Subscriptions') }}
|
||||
</h4>
|
||||
<FtFlexBox class="dataSettingsBox">
|
||||
<FtFlexBox class="box">
|
||||
<FtButton
|
||||
:label="$t('Settings.Data Settings.Import Subscriptions')"
|
||||
@click="importSubscriptions"
|
||||
@@ -29,20 +29,20 @@
|
||||
<h4 class="groupTitle">
|
||||
{{ $t('History.History') }}
|
||||
</h4>
|
||||
<FtFlexBox class="dataSettingsBox">
|
||||
<FtFlexBox class="box">
|
||||
<FtButton
|
||||
:label="$t('Settings.Data Settings.Import History')"
|
||||
@click="importHistory"
|
||||
@click="importWatchHistory"
|
||||
/>
|
||||
<FtButton
|
||||
:label="$t('Settings.Data Settings.Export History')"
|
||||
@click="exportHistory"
|
||||
@click="showExportWatchHistoryPrompt = true"
|
||||
/>
|
||||
</FtFlexBox>
|
||||
<h4 class="groupTitle">
|
||||
{{ $t('Playlists') }}
|
||||
</h4>
|
||||
<FtFlexBox class="dataSettingsBox">
|
||||
<FtFlexBox class="box">
|
||||
<FtButton
|
||||
:label="$t('Settings.Data Settings.Import Playlists')"
|
||||
@click="importPlaylists"
|
||||
@@ -52,6 +52,19 @@
|
||||
@click="exportPlaylists"
|
||||
/>
|
||||
</FtFlexBox>
|
||||
<h4 class="groupTitle">
|
||||
{{ t('Settings.Data Settings.Search history') }}
|
||||
</h4>
|
||||
<FtFlexBox class="box">
|
||||
<FtButton
|
||||
:label="t('Settings.Data Settings.Import search history')"
|
||||
@click="importSearchHistory"
|
||||
/>
|
||||
<FtButton
|
||||
:label="t('Settings.Data Settings.Export search history')"
|
||||
@click="showExportSearchHistoryPrompt = true"
|
||||
/>
|
||||
</FtFlexBox>
|
||||
<FtPrompt
|
||||
v-if="showExportSubscriptionsPrompt"
|
||||
:label="$t('Settings.Data Settings.Select Export Type')"
|
||||
@@ -59,23 +72,37 @@
|
||||
:option-values="SUBSCRIPTIONS_PROMPT_VALUES"
|
||||
@click="exportSubscriptions"
|
||||
/>
|
||||
<FtPrompt
|
||||
v-if="showExportWatchHistoryPrompt"
|
||||
:label="t('Settings.Data Settings.Select Export Type')"
|
||||
:option-names="exportWatchSearchHistoryPromptNames"
|
||||
:option-values="WATCH_SEARCH_HISTORY_PROMPT_VALUES"
|
||||
@click="exportWatchHistory"
|
||||
/>
|
||||
<FtPrompt
|
||||
v-if="showExportSearchHistoryPrompt"
|
||||
:label="t('Settings.Data Settings.Select Export Type')"
|
||||
:option-names="exportWatchSearchHistoryPromptNames"
|
||||
:option-values="WATCH_SEARCH_HISTORY_PROMPT_VALUES"
|
||||
@click="exportSearchHistory"
|
||||
/>
|
||||
</FtSettingsSection>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from '../composables/use-i18n-polyfill'
|
||||
import { useRouter } from 'vue-router/composables'
|
||||
import { useI18n } from '../../composables/use-i18n-polyfill'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import FtButton from './FtButton/FtButton.vue'
|
||||
import FtFlexBox from './ft-flex-box/ft-flex-box.vue'
|
||||
import FtPrompt from './FtPrompt/FtPrompt.vue'
|
||||
import FtSettingsSection from './FtSettingsSection/FtSettingsSection.vue'
|
||||
import FtButton from '../FtButton/FtButton.vue'
|
||||
import FtFlexBox from '../ft-flex-box/ft-flex-box.vue'
|
||||
import FtPrompt from '../FtPrompt/FtPrompt.vue'
|
||||
import FtSettingsSection from '../FtSettingsSection/FtSettingsSection.vue'
|
||||
|
||||
import store from '../store/index'
|
||||
import store from '../../store/index'
|
||||
|
||||
import { MAIN_PROFILE_ID } from '../../constants'
|
||||
import { calculateColorLuminance, getRandomColor } from '../helpers/colors'
|
||||
import { MAIN_PROFILE_ID } from '../../../constants'
|
||||
import { calculateColorLuminance, getRandomColor } from '../../helpers/colors'
|
||||
import {
|
||||
deepCopy,
|
||||
escapeHTML,
|
||||
@@ -83,8 +110,8 @@ import {
|
||||
readFileWithPicker,
|
||||
showToast,
|
||||
writeFileWithPicker,
|
||||
} from '../helpers/utils'
|
||||
import { processToBeAddedPlaylistVideo } from '../helpers/playlists'
|
||||
} from '../../helpers/utils'
|
||||
import { processToBeAddedPlaylistVideo } from '../../helpers/playlists'
|
||||
|
||||
const IMPORT_DIRECTORY_ID = 'data-settings-import'
|
||||
const START_IN_DIRECTORY = 'downloads'
|
||||
@@ -394,8 +421,6 @@ function importYouTubeSubscriptions(textDecode) {
|
||||
const subscriptions = []
|
||||
let count = 0
|
||||
|
||||
showToast(t('Settings.Data Settings.This might take a while, please wait'))
|
||||
|
||||
store.commit('setShowProgressBar', true)
|
||||
store.commit('setProgressBarPercentage', 0)
|
||||
|
||||
@@ -732,7 +757,18 @@ async function exportNewPipeSubscriptions() {
|
||||
|
||||
// #endregion subscriptions export
|
||||
|
||||
// #region history
|
||||
const WATCH_SEARCH_HISTORY_PROMPT_VALUES = [
|
||||
'freetube',
|
||||
'youtube'
|
||||
]
|
||||
|
||||
const exportWatchSearchHistoryPromptNames = computed(() => [
|
||||
`${t('Settings.Data Settings.Export FreeTube')} (.db)`,
|
||||
`${t('Settings.Data Settings.Export YouTube')} (.json)`,
|
||||
t('Close')
|
||||
])
|
||||
|
||||
// #region watch history
|
||||
|
||||
const historyCacheById = computed(() => {
|
||||
return store.getters.getHistoryCacheById
|
||||
@@ -742,7 +778,7 @@ const historyCacheSorted = computed(() => {
|
||||
return store.getters.getHistoryCacheSorted
|
||||
})
|
||||
|
||||
async function importHistory() {
|
||||
async function importWatchHistory() {
|
||||
let response
|
||||
try {
|
||||
response = await readFileWithPicker(
|
||||
@@ -767,16 +803,16 @@ async function importHistory() {
|
||||
const { filename, content } = response
|
||||
|
||||
if (filename.endsWith('.db')) {
|
||||
importFreeTubeHistory(content.split('\n'))
|
||||
importFreeTubeWatchHistory(content.split('\n'))
|
||||
} else if (filename.endsWith('.json')) {
|
||||
importYouTubeHistory(JSON.parse(content))
|
||||
importYouTubeWatchHistory(JSON.parse(content))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} textDecode
|
||||
*/
|
||||
async function importFreeTubeHistory(textDecode) {
|
||||
async function importFreeTubeWatchHistory(textDecode) {
|
||||
textDecode.pop()
|
||||
|
||||
const requiredKeys = [
|
||||
@@ -806,7 +842,8 @@ async function importFreeTubeHistory(textDecode) {
|
||||
'paid',
|
||||
]
|
||||
|
||||
const historyItems = new Map(Object.entries(historyCacheById.value))
|
||||
// deep copy so we don't get errors from Electron when we try to pass reactive objects through the IPC channels
|
||||
const historyItems = new Map(deepCopy(Object.entries(historyCacheById.value)))
|
||||
|
||||
textDecode.forEach((history) => {
|
||||
const historyData = JSON.parse(history)
|
||||
@@ -843,7 +880,7 @@ async function importFreeTubeHistory(textDecode) {
|
||||
/**
|
||||
* @param {any[]} historyData
|
||||
*/
|
||||
async function importYouTubeHistory(historyData) {
|
||||
async function importYouTubeWatchHistory(historyData) {
|
||||
const filterPredicate = item =>
|
||||
item.products.includes('YouTube') &&
|
||||
item.titleUrl != null && // removed video doesnt contain url...
|
||||
@@ -891,7 +928,8 @@ async function importYouTubeHistory(historyData) {
|
||||
'activityControls',
|
||||
].concat(Object.keys(keyMapping))
|
||||
|
||||
const historyItems = new Map(Object.entries(historyCacheById.value))
|
||||
// deep copy so we don't get errors from Electron when we try to pass reactive objects through the IPC channels
|
||||
const historyItems = new Map(deepCopy(Object.entries(historyCacheById.value)))
|
||||
|
||||
filteredHistoryData.forEach(element => {
|
||||
const historyObject = {}
|
||||
@@ -930,12 +968,30 @@ async function importYouTubeHistory(historyData) {
|
||||
showToast(t('Settings.Data Settings.All watched history has been successfully imported'))
|
||||
}
|
||||
|
||||
async function exportHistory() {
|
||||
const showExportWatchHistoryPrompt = ref(false)
|
||||
|
||||
/**
|
||||
* @param {'freetube' | 'youtube' | null} option
|
||||
*/
|
||||
async function exportWatchHistory(option) {
|
||||
showExportWatchHistoryPrompt.value = false
|
||||
|
||||
switch (option) {
|
||||
case 'freetube':
|
||||
exportFreeTubeWatchHistory()
|
||||
break
|
||||
case 'youtube':
|
||||
exportYouTubeWatchHistory()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async function exportFreeTubeWatchHistory() {
|
||||
const historyDb = historyCacheSorted.value.map((historyEntry) => {
|
||||
return JSON.stringify(historyEntry)
|
||||
}).join('\n') + '\n'
|
||||
const dateStr = getTodayDateStrLocalTimezone()
|
||||
const exportFileName = 'freetube-history-' + dateStr + '.db'
|
||||
const exportFileName = 'freetube-watch-history-' + dateStr + '.db'
|
||||
|
||||
await promptAndWriteToFile(
|
||||
exportFileName,
|
||||
@@ -947,7 +1003,40 @@ async function exportHistory() {
|
||||
)
|
||||
}
|
||||
|
||||
// #endregion history
|
||||
async function exportYouTubeWatchHistory() {
|
||||
const historyData = historyCacheSorted.value.map((entry) => {
|
||||
return {
|
||||
header: 'YouTube',
|
||||
title: `Watched ${entry.title}`,
|
||||
titleUrl: `https://www.youtube.com/watch?v=${entry.videoId}`,
|
||||
subtitles: [{
|
||||
name: entry.author,
|
||||
url: `https://www.youtube.com/channel/${entry.authorId}`
|
||||
}],
|
||||
time: new Date(entry.timeWatched).toISOString(),
|
||||
products: [
|
||||
'YouTube'
|
||||
],
|
||||
activityControls: [
|
||||
'YouTube watch history'
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const dateStr = getTodayDateStrLocalTimezone()
|
||||
const exportFileName = 'youtube-watch-history-' + dateStr + '.json'
|
||||
|
||||
await promptAndWriteToFile(
|
||||
exportFileName,
|
||||
JSON.stringify(historyData),
|
||||
t('Settings.Data Settings.History File'),
|
||||
'application/json',
|
||||
'.json',
|
||||
t('Settings.Data Settings.All watched history has been successfully exported')
|
||||
)
|
||||
}
|
||||
|
||||
// #endregion watch history
|
||||
|
||||
// #region playlists
|
||||
|
||||
@@ -1183,4 +1272,192 @@ async function exportPlaylists() {
|
||||
}
|
||||
|
||||
// #endregion playlists
|
||||
|
||||
// #region search history
|
||||
|
||||
/** @type {import('vue').ComputedRef<{ _id: string, lastUpdatedAt: number }[]>} */
|
||||
const searchHistoryEntries = computed(() => {
|
||||
return store.getters.getSearchHistoryEntries
|
||||
})
|
||||
|
||||
async function importSearchHistory() {
|
||||
let response
|
||||
try {
|
||||
response = await readFileWithPicker(
|
||||
t('Settings.Data Settings.Search history file'),
|
||||
{
|
||||
'application/x-freetube-db': '.db',
|
||||
'application/json': '.json'
|
||||
},
|
||||
IMPORT_DIRECTORY_ID,
|
||||
START_IN_DIRECTORY
|
||||
)
|
||||
} catch (err) {
|
||||
const message = t('Settings.Data Settings.Unable to read file')
|
||||
showToast(`${message}: ${err}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (response === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const { filename, content } = response
|
||||
|
||||
if (filename.endsWith('.db')) {
|
||||
importFreeTubeSearchHistory(content.split('\n'))
|
||||
} else if (filename.endsWith('.json')) {
|
||||
importYouTubeSearchHistory(JSON.parse(content))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} textDecode
|
||||
*/
|
||||
async function importFreeTubeSearchHistory(textDecode) {
|
||||
textDecode.pop()
|
||||
|
||||
// deep copy so we don't get errors from Electron when we try to pass reactive objects through the IPC channels
|
||||
const historyItems = new Map(deepCopy(searchHistoryEntries.value).map(entry => [entry._id, entry]))
|
||||
|
||||
textDecode.forEach((rawEntry) => {
|
||||
const entry = JSON.parse(rawEntry)
|
||||
|
||||
if (typeof entry._id !== 'string' || typeof entry.lastUpdatedAt !== 'number') {
|
||||
showToast(t('Settings.Data Settings.History object has insufficient data, skipping item'))
|
||||
console.error('Missing keys:', entry)
|
||||
} else {
|
||||
const existingEntry = historyItems.get(entry._id)
|
||||
|
||||
if (existingEntry == null || entry.lastUpdatedAt > existingEntry.lastUpdatedAt) {
|
||||
let newEntry
|
||||
|
||||
if (Object.keys(entry) === 2) {
|
||||
newEntry = entry
|
||||
} else {
|
||||
newEntry = { _id: entry._id, lastUpdatedAt: entry.lastUpdatedAt }
|
||||
}
|
||||
|
||||
historyItems.set(entry._id, newEntry)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const newSearchHistoryEntries = Array.from(historyItems.values())
|
||||
|
||||
await store.dispatch('overwriteSearchHistory', newSearchHistoryEntries)
|
||||
|
||||
showToast(t('Settings.Data Settings.All search history has been successfully imported'))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any[]} historyData
|
||||
*/
|
||||
async function importYouTubeSearchHistory(historyData) {
|
||||
// deep copy so we don't get errors from Electron when we try to pass reactive objects through the IPC channels
|
||||
const historyItems = new Map(deepCopy(searchHistoryEntries.value).map(entry => [entry._id, entry]))
|
||||
|
||||
for (const entry of historyData) {
|
||||
if (
|
||||
entry.products?.includes('YouTube') &&
|
||||
entry.titleUrl?.includes('youtube.com/results?search_query') &&
|
||||
entry.details == null // dont import ads
|
||||
) {
|
||||
try {
|
||||
const url = new URL(entry.titleUrl)
|
||||
const query = url.searchParams.get('search_query')
|
||||
|
||||
const lastUpdatedAt = Date.parse(entry.time)
|
||||
|
||||
if (!query || typeof query !== 'string' || query.length === 0 || isNaN(lastUpdatedAt)) {
|
||||
showToast(t('Settings.Data Settings.History object has insufficient data, skipping item'))
|
||||
console.error('Missing keys:', entry)
|
||||
} else {
|
||||
const existingEntry = historyItems.get(query)
|
||||
|
||||
if (existingEntry == null || lastUpdatedAt > existingEntry.lastUpdatedAt) {
|
||||
historyItems.set(query, { _id: query, lastUpdatedAt })
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
showToast(t('Settings.Data Settings.History object has insufficient data, skipping item'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newSearchHistoryEntries = Array.from(historyItems.values())
|
||||
|
||||
await store.dispatch('overwriteSearchHistory', newSearchHistoryEntries)
|
||||
|
||||
showToast(t('Settings.Data Settings.All search history has been successfully imported'))
|
||||
}
|
||||
|
||||
const showExportSearchHistoryPrompt = ref(false)
|
||||
|
||||
/**
|
||||
* @param {'freetube' | 'youtube' | null} option
|
||||
*/
|
||||
async function exportSearchHistory(option) {
|
||||
showExportSearchHistoryPrompt.value = false
|
||||
|
||||
switch (option) {
|
||||
case 'freetube':
|
||||
exportFreeTubeSearchHistory()
|
||||
break
|
||||
case 'youtube':
|
||||
exportYouTubeSearchHistory()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async function exportFreeTubeSearchHistory() {
|
||||
const historyDb = searchHistoryEntries.value.map((entry) => {
|
||||
return JSON.stringify(entry)
|
||||
}).join('\n') + '\n'
|
||||
const dateStr = getTodayDateStrLocalTimezone()
|
||||
const exportFileName = 'freetube-search-history-' + dateStr + '.db'
|
||||
|
||||
await promptAndWriteToFile(
|
||||
exportFileName,
|
||||
historyDb,
|
||||
t('Settings.Data Settings.Search history file'),
|
||||
'application/x-freetube-db',
|
||||
'.db',
|
||||
t('Settings.Data Settings.All search history has been successfully exported')
|
||||
)
|
||||
}
|
||||
|
||||
async function exportYouTubeSearchHistory() {
|
||||
const historyData = searchHistoryEntries.value.map((entry) => {
|
||||
return {
|
||||
header: 'YouTube',
|
||||
title: `Searched for ${entry._id}`,
|
||||
titleUrl: `https://www.youtube.com/results?search_query=${encodeURIComponent(entry._id)}`,
|
||||
time: new Date(entry.lastUpdatedAt).toISOString(),
|
||||
products: [
|
||||
'YouTube'
|
||||
],
|
||||
activityControls: [
|
||||
'YouTube search history'
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const dateStr = getTodayDateStrLocalTimezone()
|
||||
const exportFileName = 'youtube-search-history-' + dateStr + '.json'
|
||||
|
||||
await promptAndWriteToFile(
|
||||
exportFileName,
|
||||
JSON.stringify(historyData),
|
||||
t('Settings.Data Settings.Search history file'),
|
||||
'application/json',
|
||||
'.json',
|
||||
t('Settings.Data Settings.All search history has been successfully exported')
|
||||
)
|
||||
}
|
||||
|
||||
// #endregion search history
|
||||
</script>
|
||||
|
||||
<style scoped src="./DataSettings.css" />
|
||||
@@ -78,12 +78,12 @@
|
||||
</FtFlexBox>
|
||||
<FtFlexBox class="containingTextFlexBox">
|
||||
<FtInputTags
|
||||
:label="t('Settings.Distraction Free Settings.Hide Videos and Playlists Containing Text')"
|
||||
:tag-name-placeholder="t('Settings.Distraction Free Settings.Hide Videos and Playlists Containing Text Placeholder')"
|
||||
:label="t('Settings.Distraction Free Settings.Hide Videos, Playlists and Channels Containing Text')"
|
||||
:tag-name-placeholder="t('Settings.Distraction Free Settings.Hide Videos, Playlists and Channels Containing Text Placeholder')"
|
||||
:show-tags="showAddedForbiddenTitles"
|
||||
:tag-list="forbiddenTitles"
|
||||
:min-input-length="3"
|
||||
:tooltip="t('Tooltips.Distraction Free Settings.Hide Videos and Playlists Containing Text')"
|
||||
:min-input-length="1"
|
||||
:tooltip="t('Tooltips.Distraction Free Settings.Hide Videos, Playlists and Channels Containing Text')"
|
||||
@change="handleForbiddenTitles"
|
||||
@toggle-show-tags="handleAddedForbiddenTitles"
|
||||
/>
|
||||
@@ -96,8 +96,10 @@
|
||||
<div class="switchColumnGrid">
|
||||
<div class="switchColumn">
|
||||
<FtToggleSwitch
|
||||
v-if="SUPPORTS_LOCAL_API"
|
||||
:label="t('Settings.Distraction Free Settings.Hide Trending Videos')"
|
||||
:compact="true"
|
||||
:disabled="disableHideTrendingVideos"
|
||||
:default-value="hideTrendingVideos"
|
||||
@change="updateHideTrendingVideos"
|
||||
/>
|
||||
@@ -298,6 +300,8 @@ import { checkYoutubeChannelId, findChannelTagInfo } from '../../helpers/channel
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const SUPPORTS_LOCAL_API = process.env.SUPPORTS_LOCAL_API
|
||||
|
||||
const channelHiderDisabled = ref(false)
|
||||
|
||||
/** @type {import('vue').ComputedRef<'local' | 'invidious'>} */
|
||||
@@ -368,6 +372,7 @@ function handleHideRecommendedVideos(value) {
|
||||
/** @type {import('vue').ComputedRef<boolean>} */
|
||||
const hideTrendingVideos = computed(() => store.getters.getHideTrendingVideos)
|
||||
|
||||
const disableHideTrendingVideos = computed(() => backendPreference.value !== 'local' && !backendFallback.value)
|
||||
/**
|
||||
* @param {boolean} value
|
||||
*/
|
||||
|
||||
@@ -53,7 +53,7 @@ import { useI18n } from '../../composables/use-i18n-polyfill'
|
||||
|
||||
import FtButton from '../FtButton/FtButton.vue'
|
||||
import FtFlexBox from '../ft-flex-box/ft-flex-box.vue'
|
||||
import FtInput from '../ft-input/ft-input.vue'
|
||||
import FtInput from '../FtInput/FtInput.vue'
|
||||
import FtSelect from '../FtSelect/FtSelect.vue'
|
||||
import FtSettingsSection from '../FtSettingsSection/FtSettingsSection.vue'
|
||||
import FtToggleSwitch from '../FtToggleSwitch/FtToggleSwitch.vue'
|
||||
|
||||
@@ -52,7 +52,9 @@
|
||||
:tag-name-placeholder="$t('Settings.External Player Settings.Custom External Player Arguments')"
|
||||
:tag-list="externalPlayerCustomArgs"
|
||||
:tooltip="externalPlayerCustomArgsTooltip"
|
||||
:show-tags="showAddedExternalPlayerCustomArgs"
|
||||
@change="handleExternalPlayerCustomArgs"
|
||||
@toggle-show-tags="handleAddedExternalPayerCustomArgs"
|
||||
/>
|
||||
</FtFlexBox>
|
||||
</FtSettingsSection>
|
||||
@@ -64,7 +66,7 @@ import { useI18n } from '../composables/use-i18n-polyfill'
|
||||
|
||||
import FtSettingsSection from './FtSettingsSection/FtSettingsSection.vue'
|
||||
import FtSelect from './FtSelect/FtSelect.vue'
|
||||
import FtInput from './ft-input/ft-input.vue'
|
||||
import FtInput from './FtInput/FtInput.vue'
|
||||
import FtToggleSwitch from './FtToggleSwitch/FtToggleSwitch.vue'
|
||||
import FtFlexBox from './ft-flex-box/ft-flex-box.vue'
|
||||
import FtInputTags from './FtInputTags/FtInputTags.vue'
|
||||
@@ -150,4 +152,11 @@ function updateExternalPlayerExecutable(value) {
|
||||
function handleExternalPlayerCustomArgs(args) {
|
||||
store.dispatch('updateExternalPlayerCustomArgs', JSON.stringify(args))
|
||||
}
|
||||
|
||||
/** @type {import('vue').ComputedRef<boolean>} */
|
||||
const showAddedExternalPlayerCustomArgs = computed(() => store.getters.getShowAddedExternalPlayerCustomArgs)
|
||||
|
||||
function handleAddedExternalPayerCustomArgs() {
|
||||
store.dispatch('updateShowAddedExternalPlayerCustomArgs', !showAddedExternalPlayerCustomArgs.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -58,8 +58,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useId } from '../../composables/use-id-polyfill'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import { useId } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
channelId: {
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
</h3>
|
||||
<template
|
||||
v-for="(label, index) in labels"
|
||||
:key="values[index]"
|
||||
>
|
||||
<input
|
||||
:id="id + values[index]"
|
||||
:key="'value' + values[index]"
|
||||
v-model="modelValue"
|
||||
:name="id"
|
||||
:value="values[index]"
|
||||
@@ -17,7 +17,6 @@
|
||||
type="checkbox"
|
||||
>
|
||||
<label
|
||||
:key="'label' + values[index]"
|
||||
:for="id + values[index]"
|
||||
>
|
||||
{{ label }}
|
||||
@@ -27,12 +26,11 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useId } from '../../composables/use-id-polyfill'
|
||||
import { useId } from 'vue'
|
||||
|
||||
const id = useId()
|
||||
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
@@ -49,39 +47,9 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
// Required for v-model in the parent component (https://v2.vuejs.org/v2/guide/components#Using-v-model-on-Components)
|
||||
// Do not rename or remove
|
||||
// TODO: Replace with defineModel in Vue 3
|
||||
value: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
// Required for v-model in the parent component (https://v2.vuejs.org/v2/guide/components#Using-v-model-on-Components)
|
||||
// Do not rename or remove
|
||||
// TODO: Replace with defineModel in Vue 3
|
||||
const emit = defineEmits(['input'])
|
||||
|
||||
/** @type {import('vue').Ref<string[]>} */
|
||||
const modelValue = ref(props.value)
|
||||
|
||||
watch(
|
||||
modelValue,
|
||||
(newValue) => {
|
||||
emit('input', newValue)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
(newValue) => {
|
||||
modelValue.value = newValue
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
const modelValue = defineModel({ type: Array, required: true })
|
||||
</script>
|
||||
|
||||
<style scoped src="./FtCheckboxList.css" />
|
||||
|
||||
@@ -67,11 +67,11 @@
|
||||
|
||||
.bottomSection {
|
||||
color: var(--tertiary-text-color);
|
||||
display: block;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 15px;
|
||||
margin-block-start: 4px;
|
||||
max-inline-size: 210px;
|
||||
max-inline-size: 100%;
|
||||
text-align: start;
|
||||
|
||||
@media screen and (width <= 680px) {
|
||||
@@ -88,6 +88,10 @@
|
||||
.likeCount {
|
||||
margin-inline: 5px 6px;
|
||||
}
|
||||
|
||||
.shareButton {
|
||||
margin-inline-start: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.playlistWrapper {
|
||||
|
||||
@@ -117,8 +117,8 @@
|
||||
>
|
||||
<span
|
||||
class="likeCount"
|
||||
:title="$tc('Global.Counts.Like Count', voteCount, {count: formattedVoteCount})"
|
||||
:aria-label="$tc('Global.Counts.Like Count', voteCount, {count: formattedVoteCount})"
|
||||
:title="$t('Global.Counts.Like Count', {count: formattedVoteCount}, voteCount)"
|
||||
:aria-label="$t('Global.Counts.Like Count', {count: formattedVoteCount}, voteCount)"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
class="thumbs-up-icon"
|
||||
@@ -136,8 +136,8 @@
|
||||
>
|
||||
<span
|
||||
class="commentCount"
|
||||
:title="$tc('Global.Counts.Comment Count', commentCount, {count: formattedCommentCount})"
|
||||
:aria-label="$tc('Global.Counts.Comment Count', commentCount, {count: formattedCommentCount})"
|
||||
:title="$t('Global.Counts.Comment Count', {count: formattedCommentCount}, commentCount)"
|
||||
:aria-label="$t('Global.Counts.Comment Count', {count: formattedCommentCount}, commentCount)"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
class="comment-count-icon"
|
||||
@@ -148,13 +148,19 @@
|
||||
<span
|
||||
v-else-if="commentCount != null"
|
||||
class="commentCount"
|
||||
:title="$tc('Global.Counts.Comment Count', commentCount, {count: formattedCommentCount})"
|
||||
:aria-label="$tc('Global.Counts.Comment Count', commentCount, {count: formattedCommentCount})"
|
||||
:title="$t('Global.Counts.Comment Count', {count: formattedCommentCount}, commentCount)"
|
||||
:aria-label="$t('Global.Counts.Comment Count', {count: formattedCommentCount}, commentCount)"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
class="comment-count-icon"
|
||||
:icon="['fas', 'comment']"
|
||||
/> {{ commentCount }}</span>
|
||||
<FtShareButton
|
||||
:id="postId"
|
||||
share-target-type="Post"
|
||||
class="shareButton"
|
||||
:size="18"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -163,11 +169,12 @@
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import autolinker from 'autolinker'
|
||||
import { A11y, Navigation, Pagination } from 'swiper/modules'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, useTemplateRef } from 'vue'
|
||||
|
||||
import FtListVideo from '../ft-list-video/ft-list-video.vue'
|
||||
import FtListPlaylist from '../FtListPlaylist/FtListPlaylist.vue'
|
||||
import FtCommunityPoll from '../FtCommunityPoll/FtCommunityPoll.vue'
|
||||
import FtShareButton from '../FtShareButton/FtShareButton.vue'
|
||||
|
||||
import store from '../../store/index'
|
||||
|
||||
@@ -305,7 +312,7 @@ function getBestQualityImage(imageArray) {
|
||||
return imageArrayCopy[0]?.url?.replace(/-c-fcrop64=[^-]+/i, '') ?? ''
|
||||
}
|
||||
|
||||
const swiperContainerRef = ref(null)
|
||||
const swiperContainerRef = useTemplateRef('swiperContainerRef')
|
||||
|
||||
if (postType === 'multiImage' && postContent.content.length > 0) {
|
||||
onMounted(() => {
|
||||
|
||||
@@ -43,12 +43,12 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from '../../composables/use-i18n-polyfill'
|
||||
|
||||
import FtButton from '../FtButton/FtButton.vue'
|
||||
import FtFlexBox from '../ft-flex-box/ft-flex-box.vue'
|
||||
import FtInput from '../ft-input/ft-input.vue'
|
||||
import FtInput from '../FtInput/FtInput.vue'
|
||||
import FtPrompt from '../FtPrompt/FtPrompt.vue'
|
||||
|
||||
import store from '../../store/index'
|
||||
@@ -83,7 +83,7 @@ const playlistPersistenceDisabled = computed(() => {
|
||||
return playlistName.value === '' || playlistNameBlank.value || playlistWithNameExists.value
|
||||
})
|
||||
|
||||
const playlistNameInput = ref(null)
|
||||
const playlistNameInput = useTemplateRef('playlistNameInput')
|
||||
|
||||
onMounted(() => {
|
||||
// Faster to input required playlist name
|
||||
|
||||
@@ -44,8 +44,7 @@ body[dir='rtl'] .ft-input-component.search.showClearTextButton:focus-within .inp
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.disabled label,
|
||||
.disabled .ft-input {
|
||||
.disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -61,7 +60,11 @@ body[dir='rtl'] .ft-input-component.search.showClearTextButton:focus-within .inp
|
||||
margin-inline: 3px;
|
||||
padding: 10px;
|
||||
border-radius: 100%;
|
||||
border-style: none;
|
||||
background-color: transparent;
|
||||
color: var(--primary-text-color);
|
||||
font-size: 1em;
|
||||
line-height: 1em;
|
||||
opacity: 0;
|
||||
transition: background 0.2s ease-in;
|
||||
}
|
||||
@@ -130,6 +133,10 @@ body[dir='rtl'] .ft-input-component.search.showClearTextButton:focus-within .inp
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.buttonIcon {
|
||||
inline-size: 1em;
|
||||
}
|
||||
|
||||
.inputAction {
|
||||
position: absolute;
|
||||
margin-block: 0;
|
||||
@@ -137,8 +144,12 @@ body[dir='rtl'] .ft-input-component.search.showClearTextButton:focus-within .inp
|
||||
padding: 10px;
|
||||
inset-block-start: -8px;
|
||||
inset-inline-end: 0;
|
||||
border-style: none;
|
||||
border-radius: 100%;
|
||||
background-color: transparent;
|
||||
color: var(--primary-text-color);
|
||||
font-size: 1em;
|
||||
line-height: 1em;
|
||||
|
||||
/* this should look disabled by default */
|
||||
opacity: 0.5;
|
||||
520
src/renderer/components/FtInput/FtInput.vue
Normal file
520
src/renderer/components/FtInput/FtInput.vue
Normal file
@@ -0,0 +1,520 @@
|
||||
<!-- eslint-disable vuejs-accessibility/mouse-events-have-key-events -->
|
||||
<template>
|
||||
<div
|
||||
class="ft-input-component"
|
||||
:class="{
|
||||
search: isSearch,
|
||||
forceTextColor,
|
||||
showActionButton,
|
||||
showClearTextButton,
|
||||
clearTextButtonVisible: inputDataPresent || showOptions,
|
||||
inputDataPresent,
|
||||
showOptions
|
||||
}"
|
||||
>
|
||||
<label
|
||||
v-if="showLabel"
|
||||
:for="id"
|
||||
class="selectLabel"
|
||||
:class="{ disabled }"
|
||||
>
|
||||
{{ label || placeholder }}
|
||||
<FtTooltip
|
||||
v-if="tooltip !== ''"
|
||||
class="selectTooltip"
|
||||
position="bottom"
|
||||
:tooltip="tooltip"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
v-if="showClearTextButton"
|
||||
class="clearInputTextButton"
|
||||
:class="{
|
||||
visible: inputDataPresent || showOptions
|
||||
}"
|
||||
:aria-label="t('Search Bar.Clear Input')"
|
||||
:title="t('Search Bar.Clear Input')"
|
||||
@click="handleClearTextClick"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
class="buttonIcon"
|
||||
:icon="['fas', 'times-circle']"
|
||||
/>
|
||||
</button>
|
||||
<span class="inputWrapper">
|
||||
<input
|
||||
:id="id"
|
||||
ref="inputRef"
|
||||
:value="inputDataDisplayed"
|
||||
class="ft-input"
|
||||
:class="{ disabled }"
|
||||
:maxlength="maxlength"
|
||||
:type="inputType"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:spellcheck="false"
|
||||
:aria-label="showLabel ? null : placeholder"
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@blur="handleInputBlur"
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
<button
|
||||
v-if="showActionButton"
|
||||
class="inputAction"
|
||||
:class="{
|
||||
enabled: inputDataPresent,
|
||||
withLabel: showLabel
|
||||
}"
|
||||
@click="handleClick"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
class="buttonIcon"
|
||||
:icon="actionButtonIconName"
|
||||
/>
|
||||
</button>
|
||||
</span>
|
||||
<div class="options">
|
||||
<ul
|
||||
v-if="showOptions"
|
||||
class="list"
|
||||
@mouseenter="searchState.isPointerInList = true"
|
||||
@mouseleave="searchState.isPointerInList = false"
|
||||
>
|
||||
<!-- eslint-disable vuejs-accessibility/click-events-have-key-events -->
|
||||
<li
|
||||
v-for="(entry, index) in visibleDataList"
|
||||
:key="index"
|
||||
:class="{ hover: searchState.selectedOption === index }"
|
||||
@click="handleOptionClick(index)"
|
||||
@mouseenter="searchState.selectedOption = index"
|
||||
@mouseleave="resetSelectedOption"
|
||||
>
|
||||
<div class="optionWrapper">
|
||||
<FontAwesomeIcon
|
||||
v-if="dataListProperties[index]?.iconName"
|
||||
:icon="['fas', dataListProperties[index].iconName]"
|
||||
class="searchResultIcon"
|
||||
/>
|
||||
<span>{{ entry }}</span>
|
||||
</div>
|
||||
<a
|
||||
v-if="dataListProperties[index]?.isRemoveable"
|
||||
class="removeButton"
|
||||
:class="{ removeButtonSelected: removeButtonSelectedIndex === index }"
|
||||
role="button"
|
||||
:aria-label="t('Search Bar.Remove')"
|
||||
href="javascript:void(0)"
|
||||
@click.prevent.stop="handleRemoveClick(index)"
|
||||
>
|
||||
{{ t('Search Bar.Remove') }}
|
||||
</a>
|
||||
</li>
|
||||
<!-- skipped -->
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import { computed, reactive, ref, shallowRef, useId, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from '../../composables/use-i18n-polyfill'
|
||||
|
||||
import FtTooltip from '../FtTooltip/FtTooltip.vue'
|
||||
|
||||
import store from '../../store/index'
|
||||
|
||||
import { isKeyboardEventKeyPrintableChar, isNullOrEmpty } from '../../helpers/strings'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
inputType: {
|
||||
type: String,
|
||||
default: 'text'
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
maxlength: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
showActionButton: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
forceActionButtonIconName: {
|
||||
type: Array,
|
||||
default: null
|
||||
},
|
||||
showClearTextButton: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showLabel: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isSearch: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
dataList: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
dataListProperties: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
searchResultIconNames: {
|
||||
type: Array,
|
||||
default: null
|
||||
},
|
||||
showDataWhenEmpty: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
tooltip: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['clear', 'click', 'input', 'remove'])
|
||||
|
||||
const id = useId()
|
||||
|
||||
const inputRef = useTemplateRef('inputRef')
|
||||
|
||||
const inputData = ref(props.value)
|
||||
const searchState = reactive({
|
||||
showOptions: false,
|
||||
selectedOption: -1,
|
||||
isPointerInList: false,
|
||||
keyboardSelectedOptionIndex: -1
|
||||
})
|
||||
const visibleDataList = ref(props.dataList)
|
||||
const removeButtonSelectedIndex = ref(-1)
|
||||
const removalMade = ref(false)
|
||||
const actionButtonIconName = shallowRef(props.forceActionButtonIconName ?? ['fas', 'search'])
|
||||
|
||||
const showOptions = computed(() => {
|
||||
return (inputData.value !== '' || props.showDataWhenEmpty) && visibleDataList.value.length > 0 && searchState.showOptions
|
||||
})
|
||||
|
||||
const forceTextColor = computed(() => props.isSearch && store.getters.getBarColor)
|
||||
|
||||
const searchStateKeyboardSelectedOptionValue = computed(() => {
|
||||
return searchState.keyboardSelectedOptionIndex === -1
|
||||
? null
|
||||
: visibleDataList.value[searchState.keyboardSelectedOptionIndex]
|
||||
})
|
||||
|
||||
const inputDataDisplayed = computed(() => {
|
||||
if (!props.isSearch) { return inputData.value }
|
||||
|
||||
/** @type {string | null | undefined} */
|
||||
const selectedOptionValue = searchStateKeyboardSelectedOptionValue.value
|
||||
if (selectedOptionValue != null && selectedOptionValue !== '') {
|
||||
return selectedOptionValue
|
||||
}
|
||||
|
||||
return inputData.value
|
||||
})
|
||||
|
||||
const inputDataPresent = computed(() => inputDataDisplayed.value.length > 0)
|
||||
|
||||
watch(() => props.dataList, updateVisibleDataList, { deep: true })
|
||||
watch(inputData, updateVisibleDataList)
|
||||
watch(() => props.value, (value) => {
|
||||
inputData.value = value
|
||||
})
|
||||
|
||||
updateVisibleDataList()
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent | MouseEvent} [event]
|
||||
*/
|
||||
function handleClick(event) {
|
||||
const selectedValue = searchStateKeyboardSelectedOptionValue.value
|
||||
const query = (selectedValue != null && selectedValue !== '') ? selectedValue : inputData.value
|
||||
inputData.value = query
|
||||
|
||||
// No action if no input text
|
||||
if (!inputDataPresent.value) {
|
||||
return
|
||||
}
|
||||
|
||||
searchState.showOptions = false
|
||||
searchState.selectedOption = -1
|
||||
searchState.keyboardSelectedOptionIndex = -1
|
||||
removeButtonSelectedIndex.value = -1
|
||||
|
||||
emit('input', query)
|
||||
emit('click', query, { event })
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | InputEvent} data
|
||||
*/
|
||||
function handleInput(data) {
|
||||
const text = typeof data === 'string' ? data : inputRef.value.value
|
||||
inputData.value = text
|
||||
|
||||
if (
|
||||
props.isSearch &&
|
||||
searchState.selectedOption !== -1 &&
|
||||
inputData.value === visibleDataList.value[searchState.selectedOption]
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
handleActionIconChange()
|
||||
emit('input', text)
|
||||
}
|
||||
|
||||
function handleClearTextClick() {
|
||||
// No action if no input text
|
||||
if (!inputDataPresent.value) { return }
|
||||
|
||||
inputData.value = ''
|
||||
handleActionIconChange()
|
||||
updateVisibleDataList()
|
||||
searchState.isPointerInList = false
|
||||
|
||||
inputRef.value.value = ''
|
||||
|
||||
// Focus on input element after text is clear for better UX
|
||||
inputRef.value.focus()
|
||||
|
||||
emit('clear')
|
||||
}
|
||||
|
||||
async function handleActionIconChange() {
|
||||
// Only need to update icon if visible
|
||||
if (!props.showActionButton) { return }
|
||||
|
||||
if (!inputDataPresent.value && props.forceActionButtonIconName === null) {
|
||||
// Change back to default icon if text is blank
|
||||
actionButtonIconName.value = ['fas', 'search']
|
||||
return
|
||||
}
|
||||
|
||||
// Update action button icon according to input
|
||||
try {
|
||||
const result = await store.dispatch('getYoutubeUrlInfo', inputData.value)
|
||||
|
||||
let isYoutubeLink = false
|
||||
|
||||
switch (result.urlType) {
|
||||
case 'video':
|
||||
case 'playlist':
|
||||
case 'search':
|
||||
case 'channel':
|
||||
case 'hashtag':
|
||||
case 'post':
|
||||
case 'trending':
|
||||
case 'subscriptions':
|
||||
case 'history':
|
||||
case 'userplaylists':
|
||||
isYoutubeLink = true
|
||||
break
|
||||
|
||||
case 'invalid_url':
|
||||
default: {
|
||||
// isYoutubeLink is already `false`
|
||||
}
|
||||
}
|
||||
|
||||
if (props.forceActionButtonIconName === null) {
|
||||
if (isYoutubeLink) {
|
||||
// Go to URL (i.e. Video/Playlist/Channel
|
||||
actionButtonIconName.value = ['fas', 'arrow-right']
|
||||
} else {
|
||||
// Search with text
|
||||
actionButtonIconName.value = ['fas', 'search']
|
||||
}
|
||||
}
|
||||
} catch (ex) {
|
||||
// On exception, consider text as invalid URL
|
||||
if (props.forceActionButtonIconName === null) {
|
||||
actionButtonIconName.value = ['fas', 'search']
|
||||
}
|
||||
|
||||
// Rethrow exception
|
||||
throw ex
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} index
|
||||
*/
|
||||
function handleOptionClick(index) {
|
||||
if (removeButtonSelectedIndex.value !== -1) {
|
||||
handleRemoveClick(index)
|
||||
return
|
||||
}
|
||||
|
||||
searchState.showOptions = false
|
||||
inputData.value = visibleDataList.value[index]
|
||||
emit('input', inputData.value)
|
||||
handleClick()
|
||||
}
|
||||
|
||||
function resetSelectedOption() {
|
||||
searchState.selectedOption = -1
|
||||
removeButtonSelectedIndex.value = -1
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} index
|
||||
*/
|
||||
function handleRemoveClick(index) {
|
||||
if (!props.dataListProperties[index]?.isRemoveable) { return }
|
||||
|
||||
// keep input in focus even when the to-be-removed "Remove" button was clicked
|
||||
inputRef.value.focus()
|
||||
removalMade.value = true
|
||||
emit('remove', visibleDataList.value[index])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
function handleKeyDown(event) {
|
||||
// Update Input box value if enter key was pressed and option selected
|
||||
if (event.key === 'Enter' && !event.isComposing) {
|
||||
if (removeButtonSelectedIndex.value !== -1) {
|
||||
handleRemoveClick(removeButtonSelectedIndex.value)
|
||||
} else if (searchState.selectedOption !== -1) {
|
||||
searchState.showOptions = false
|
||||
event.preventDefault()
|
||||
inputData.value = visibleDataList.value[searchState.selectedOption]
|
||||
handleOptionClick(searchState.selectedOption)
|
||||
} else {
|
||||
handleClick(event)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (visibleDataList.value.length === 0) { return }
|
||||
|
||||
searchState.showOptions = true
|
||||
|
||||
// "select" the Remove button through right arrow navigation, and unselect it with the left arrow
|
||||
if (event.key === 'ArrowRight') {
|
||||
removeButtonSelectedIndex.value = searchState.selectedOption
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
removeButtonSelectedIndex.value = -1
|
||||
} else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
const newIndex = searchState.selectedOption + (event.key === 'ArrowDown' ? 1 : -1)
|
||||
updateSelectedOptionIndex(newIndex)
|
||||
} else {
|
||||
const selectedOptionValue = searchStateKeyboardSelectedOptionValue.value
|
||||
|
||||
// Keyboard selected & is char
|
||||
if (!isNullOrEmpty(selectedOptionValue) && isKeyboardEventKeyPrintableChar(event.key)) {
|
||||
// Update input based on KB selected suggestion value instead of current input value
|
||||
event.preventDefault()
|
||||
handleInput(`${selectedOptionValue}${event.key}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the selected dropdown option index and handles the under/over-flow behavior
|
||||
* @param {number} index
|
||||
*/
|
||||
function updateSelectedOptionIndex(index) {
|
||||
searchState.selectedOption = index
|
||||
|
||||
// unset selection of "Remove" button
|
||||
removeButtonSelectedIndex.value = -1
|
||||
|
||||
// Allow deselecting suggestion
|
||||
if (searchState.selectedOption < -1) {
|
||||
searchState.selectedOption = visibleDataList.value.length - 1
|
||||
} else if (searchState.selectedOption > visibleDataList.value.length - 1) {
|
||||
searchState.selectedOption = -1
|
||||
}
|
||||
|
||||
// Update displayed value
|
||||
searchState.keyboardSelectedOptionIndex = searchState.selectedOption
|
||||
}
|
||||
|
||||
function handleInputBlur() {
|
||||
if (!searchState.isPointerInList) {
|
||||
searchState.showOptions = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
searchState.showOptions = true
|
||||
}
|
||||
|
||||
function updateVisibleDataList() {
|
||||
// Reset selected option before it's updated
|
||||
// Block resetting if it was just the "Remove" button that was pressed
|
||||
if (!removalMade.value || searchState.selectedOption >= props.dataList.length) {
|
||||
searchState.selectedOption = -1
|
||||
searchState.keyboardSelectedOptionIndex = -1
|
||||
removeButtonSelectedIndex.value = -1
|
||||
}
|
||||
|
||||
removalMade.value = false
|
||||
|
||||
if (inputData.value.trim() === '') {
|
||||
visibleDataList.value = props.dataList
|
||||
return
|
||||
}
|
||||
// get list of items that match input
|
||||
const lowerCaseInputData = inputData.value.toLowerCase()
|
||||
|
||||
visibleDataList.value = props.dataList.filter(x => {
|
||||
return x.toLowerCase().includes(lowerCaseInputData)
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focus: () => {
|
||||
inputRef.value?.focus()
|
||||
},
|
||||
blur: () => {
|
||||
inputRef.value?.blur()
|
||||
},
|
||||
select: () => {
|
||||
inputRef.value?.select()
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
*/
|
||||
setText: (text) => {
|
||||
inputData.value = text
|
||||
},
|
||||
|
||||
clear: () => {
|
||||
handleClearTextClick()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped src="./FtInput.css" />
|
||||
@@ -55,7 +55,11 @@
|
||||
}
|
||||
|
||||
.removeTagButton {
|
||||
background-color: transparent;
|
||||
border-style: none;
|
||||
color: var(--primary-text-color);
|
||||
font-size: 1em;
|
||||
line-height: 1em;
|
||||
opacity: 0.5;
|
||||
padding: 10px;
|
||||
padding-inline-start: 0;
|
||||
|
||||
@@ -62,15 +62,15 @@
|
||||
<span>{{ (tag.preferredName) ? tag.preferredName : tag.name }}</span>
|
||||
</template>
|
||||
<span v-else>{{ tag }}</span>
|
||||
<FontAwesomeIcon
|
||||
<button
|
||||
v-if="!disabled"
|
||||
:icon="['fas', 'fa-times']"
|
||||
class="removeTagButton"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
@click="removeTag(tag)"
|
||||
@keydown.enter.prevent="removeTag(tag)"
|
||||
/>
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="['fas', 'fa-times']"
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -79,11 +79,10 @@
|
||||
|
||||
<script setup>
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import { ref } from 'vue'
|
||||
import { useId } from '../../composables/use-id-polyfill'
|
||||
import { useId, useTemplateRef } from 'vue'
|
||||
import { useI18n } from '../../composables/use-i18n-polyfill'
|
||||
|
||||
import FtInput from '../ft-input/ft-input.vue'
|
||||
import FtInput from '../FtInput/FtInput.vue'
|
||||
|
||||
import { showToast } from '../../helpers/utils'
|
||||
|
||||
@@ -140,7 +139,7 @@ const { t } = useI18n()
|
||||
|
||||
const id = useId()
|
||||
|
||||
const tagNameInput = ref(null)
|
||||
const tagNameInput = useTemplateRef('tagNameInput')
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
@@ -168,7 +167,7 @@ async function updateTags(text) {
|
||||
newList.push(trimmedText)
|
||||
emit('change', newList)
|
||||
// clear input box
|
||||
tagNameInput.value.handleClearTextClick()
|
||||
tagNameInput.value.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,7 +198,7 @@ async function updateChannelTags(text) {
|
||||
}
|
||||
|
||||
// clear input box
|
||||
tagNameInput.value.handleClearTextClick()
|
||||
tagNameInput.value.clear()
|
||||
}
|
||||
|
||||
function removeTag(tag) {
|
||||
|
||||
@@ -124,8 +124,6 @@ const localizedShortcutNameToShortcutsMappings = computed(() => {
|
||||
[t('KeyboardShortcutPrompt.New Window'), ['NEW_WINDOW']],
|
||||
[t('KeyboardShortcutPrompt.Minimize Window'), ['MINIMIZE_WINDOW']],
|
||||
[t('KeyboardShortcutPrompt.Close Window'), ['CLOSE_WINDOW']],
|
||||
[t('KeyboardShortcutPrompt.Restart Window'), ['RESTART_WINDOW']],
|
||||
[t('KeyboardShortcutPrompt.Force Restart Window'), ['FORCE_RESTART_WINDOW']],
|
||||
[t('KeyboardShortcutPrompt.Toggle Developer Tools'), ['TOGGLE_DEVTOOLS']],
|
||||
[t('KeyboardShortcutPrompt.Reset Zoom'), ['RESET_ZOOM']],
|
||||
[t('KeyboardShortcutPrompt.Zoom In'), ['ZOOM_IN']],
|
||||
|
||||
@@ -44,14 +44,14 @@
|
||||
class="subscriberCount"
|
||||
>
|
||||
<template v-if="handle !== null"> • </template>
|
||||
{{ $tc('Global.Counts.Subscriber Count', subscriberCount, {count: formattedSubscriberCount}) }}
|
||||
{{ $t('Global.Counts.Subscriber Count', {count: formattedSubscriberCount}, subscriberCount) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="handle == null && videoCount != null"
|
||||
class="videoCount"
|
||||
>
|
||||
<template v-if="subscriberCount !== null && !hideChannelSubscriptions"> • </template>
|
||||
{{ $tc('Global.Counts.Video Count', videoCount, {count: formattedVideoCount}) }}
|
||||
{{ $t('Global.Counts.Video Count', {count: formattedVideoCount}, videoCount) }}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
|
||||
@@ -34,14 +34,14 @@
|
||||
v-if="channelCount"
|
||||
class="channelCount"
|
||||
>
|
||||
{{ $tc('Global.Counts.Channel Count', channelCount, {count: formattedChannelCount}) }}
|
||||
{{ $t('Global.Counts.Channel Count', {count: formattedChannelCount}, channelCount) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="videoCount"
|
||||
class="videoCount"
|
||||
>
|
||||
<template v-if="channelCount"> • </template>
|
||||
{{ $tc('Global.Counts.Video Count', videoCount, {count: formattedVideosCount}) }}
|
||||
{{ $t('Global.Counts.Video Count', {count: formattedVideosCount}, videoCount) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -172,7 +172,7 @@ const channelsHidden = computed(() => {
|
||||
/** @type {string[]} */
|
||||
const forbiddenTitles = computed(() => {
|
||||
if (!props.hideForbiddenTitles) { return [] }
|
||||
return JSON.parse(store.getters.getForbiddenTitles)
|
||||
return JSON.parse(store.getters.getForbiddenTitles.toLowerCase())
|
||||
})
|
||||
|
||||
const showResult = computed(() => {
|
||||
@@ -203,13 +203,16 @@ const showResult = computed(() => {
|
||||
return false
|
||||
}
|
||||
|
||||
if (channelsHidden.value.some(ch => ch.name === props.data.authorId) || channelsHidden.value.some(ch => ch.name === props.data.author)) {
|
||||
const lowerCaseAuthor = props.data.author?.toLowerCase()
|
||||
|
||||
if (channelsHidden.value.some(ch => ch.name === props.data.authorId) || channelsHidden.value.some(ch => ch.name === props.data.author) || (forbiddenTitles.value.some((text) => lowerCaseAuthor.includes(text)))) {
|
||||
// hide videos by author
|
||||
return false
|
||||
}
|
||||
|
||||
const lowerCaseTitle = props.data.title?.toLowerCase()
|
||||
if (forbiddenTitles.value.some((text) => lowerCaseTitle.includes(text.toLowerCase()))) {
|
||||
|
||||
if (forbiddenTitles.value.some((text) => lowerCaseTitle.includes(text))) {
|
||||
return false
|
||||
}
|
||||
} else if (dataType === 'channel') {
|
||||
@@ -223,14 +226,19 @@ const showResult = computed(() => {
|
||||
props.data.authorId,
|
||||
]
|
||||
|
||||
if (attrsToCheck.some(a => a != null && channelsHidden.value.some(ch => ch.name === a))) {
|
||||
const lowerCaseName = props.data.name?.toLowerCase()
|
||||
|
||||
if ((attrsToCheck.some(a => a != null && channelsHidden.value.some(ch => ch.name === a))) ||
|
||||
(forbiddenTitles.value.some((text) => lowerCaseName.includes(text)))) {
|
||||
// hide channels by author
|
||||
return false
|
||||
}
|
||||
} else if (dataType === 'playlist') {
|
||||
const lowerCaseTitle = props.data.title?.toLowerCase()
|
||||
const lowerCaseChannelName = props.data.channelName?.toLowerCase()
|
||||
|
||||
if (forbiddenTitles.value.some((text) => lowerCaseTitle.includes(text.toLowerCase()))) {
|
||||
if ((forbiddenTitles.value.some((text) => lowerCaseTitle.includes(text))) ||
|
||||
(forbiddenTitles.value.some((text) => lowerCaseChannelName.includes(text)))) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -130,15 +130,21 @@ const channelsHidden = computed(() => {
|
||||
|
||||
const forbiddenTitles = computed(() => {
|
||||
if (!props.hideForbiddenTitles) { return [] }
|
||||
return JSON.parse(store.getters.getForbiddenTitles)
|
||||
return JSON.parse(store.getters.getForbiddenTitles.toLowerCase())
|
||||
})
|
||||
|
||||
const hideChannelsBasedOnText = computed(() => {
|
||||
return store.getters.getHideChannelsBasedOnText
|
||||
})
|
||||
|
||||
const shouldBeVisible = computed(() => {
|
||||
const lowerCaseTitle = props.data.title?.toLowerCase()
|
||||
const lowerCaseAuthor = props.data.author?.toLowerCase()
|
||||
|
||||
return !(channelsHidden.value.some(ch => ch.name === props.data.authorId) ||
|
||||
channelsHidden.value.some(ch => ch.name === props.data.author) ||
|
||||
(lowerCaseTitle && forbiddenTitles.value.some((text) => lowerCaseTitle.includes(text.toLowerCase()))))
|
||||
(lowerCaseTitle && forbiddenTitles.value.some((text) => lowerCaseTitle.includes(text))) ||
|
||||
(hideChannelsBasedOnText.value && lowerCaseAuthor && forbiddenTitles.value.some((text) => lowerCaseAuthor.includes(text))))
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,12 +7,13 @@
|
||||
color: var(--text-with-accent-color);
|
||||
*/
|
||||
margin: 4px;
|
||||
padding: 16px;
|
||||
padding-block: 3px 5px;
|
||||
padding-inline: 16px;
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / 10%);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ftNotificationBanner:focus {
|
||||
@@ -20,21 +21,25 @@
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-inline-end: 25px;
|
||||
cursor: pointer;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.bannerIcon {
|
||||
position: absolute;
|
||||
inset-block-start: 35%;
|
||||
inset-inline-end: 10px;
|
||||
.closeButton {
|
||||
background-color: transparent;
|
||||
border-style: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
line-height: 1em;
|
||||
padding: 0.4em;
|
||||
}
|
||||
|
||||
.closeIcon {
|
||||
inline-size: 1em;
|
||||
}
|
||||
|
||||
@media only screen and (width <= 680px) {
|
||||
.bannerIcon {
|
||||
inset-block-start: 27%;
|
||||
block-size: 25px;
|
||||
inline-size: 25px;
|
||||
.closeButton {
|
||||
font-size: 1.6em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,27 +9,30 @@
|
||||
@keydown.enter.prevent="handleClick"
|
||||
@keydown.space.prevent="handleClick"
|
||||
>
|
||||
<div
|
||||
<p
|
||||
:id="id"
|
||||
class="message"
|
||||
>
|
||||
<p :id="id">
|
||||
{{ message }}
|
||||
</p>
|
||||
</div>
|
||||
<FontAwesomeIcon
|
||||
class="bannerIcon"
|
||||
:icon="['fas', 'times']"
|
||||
tabindex="0"
|
||||
{{ message }}
|
||||
</p>
|
||||
<button
|
||||
class="closeButton"
|
||||
:aria-label="$t('Close Banner')"
|
||||
:title="$t('Close Banner')"
|
||||
@click.stop="handleClose"
|
||||
@keydown.enter.space.stop.prevent="handleClose"
|
||||
/>
|
||||
@click="handleClose"
|
||||
@keydown.enter.space.stop
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
class="closeIcon"
|
||||
:icon="['fas', 'times']"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import { useId } from '../../composables/use-id-polyfill'
|
||||
import { useId } from 'vue'
|
||||
|
||||
defineProps({
|
||||
message: {
|
||||
|
||||
@@ -101,14 +101,14 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from '../../composables/use-i18n-polyfill'
|
||||
|
||||
import FtFlexBox from '../ft-flex-box/ft-flex-box.vue'
|
||||
import FtPrompt from '../FtPrompt/FtPrompt.vue'
|
||||
import FtButton from '../FtButton/FtButton.vue'
|
||||
import FtPlaylistSelector from '../FtPlaylistSelector/FtPlaylistSelector.vue'
|
||||
import FtInput from '../ft-input/ft-input.vue'
|
||||
import FtInput from '../FtInput/FtInput.vue'
|
||||
import FtSelect from '../FtSelect/FtSelect.vue'
|
||||
import FtToggleSwitch from '../FtToggleSwitch/FtToggleSwitch.vue'
|
||||
|
||||
@@ -282,7 +282,7 @@ const anyPlaylistContainsVideosToBeAdded = computed(() => {
|
||||
return playlistIdsContainingVideosToBeAdded.value.size > 0
|
||||
})
|
||||
|
||||
const searchBar = ref(null)
|
||||
const searchBar = useTemplateRef('searchBar')
|
||||
|
||||
watch(allPlaylistsLength, (val, oldVal) => {
|
||||
const allPlaylistIds = new Set()
|
||||
@@ -395,19 +395,10 @@ function addSelectedToPlaylists() {
|
||||
addedPlaylistIds.add(playlist._id)
|
||||
})
|
||||
|
||||
let message
|
||||
if (addedPlaylistIds.size === 1) {
|
||||
message = t('User Playlists.AddVideoPrompt.Toast.{videoCount} video(s) added to 1 playlist', {
|
||||
videoCount: toBeAddedToPlaylistVideoCount.value,
|
||||
}, toBeAddedToPlaylistVideoCount.value)
|
||||
} else {
|
||||
message = t('User Playlists.AddVideoPrompt.Toast.{videoCount} video(s) added to {playlistCount} playlists', {
|
||||
videoCount: toBeAddedToPlaylistVideoCount.value,
|
||||
playlistCount: addedPlaylistIds.size,
|
||||
}, toBeAddedToPlaylistVideoCount.value)
|
||||
}
|
||||
showToast(t('User Playlists.AddVideoPrompt.Toast.Video(s) added to {playlistCount} playlists', {
|
||||
playlistCount: addedPlaylistIds.size,
|
||||
}, addedPlaylistIds.size))
|
||||
|
||||
showToast(message)
|
||||
hide()
|
||||
}
|
||||
|
||||
|
||||
@@ -25,8 +25,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useId } from '../../composables/use-id-polyfill'
|
||||
import { computed, useId } from 'vue'
|
||||
import { useI18n } from '../../composables/use-i18n-polyfill'
|
||||
|
||||
import { getFirstCharacter } from '../../helpers/strings'
|
||||
@@ -60,7 +59,7 @@ const translatedProfileName = computed(() => {
|
||||
|
||||
const profileInitial = computed(() => {
|
||||
return props.profileName
|
||||
? getFirstCharacter(translatedProfileName.value, locale.value).toUpperCase()
|
||||
? getFirstCharacter(translatedProfileName.value, locale.value)
|
||||
: ''
|
||||
})
|
||||
|
||||
|
||||
@@ -222,14 +222,15 @@ function handleDeletePromptClick(value) {
|
||||
showToast(t('Profile.Profile has been updated'))
|
||||
selectNone()
|
||||
} else {
|
||||
/** @type {Profile} */
|
||||
const profile = deepCopy(props.profile)
|
||||
|
||||
subscriptions.value = subscriptions.value.filter((channel) => {
|
||||
return !selected_.includes(channel.id)
|
||||
})
|
||||
|
||||
profile.subscriptions = subscriptions.value
|
||||
/** @type {Profile} */
|
||||
const profile = {
|
||||
...props.profile,
|
||||
subscriptions: deepCopy(subscriptions.value)
|
||||
}
|
||||
|
||||
store.dispatch('updateProfile', profile)
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
:show-action-button="false"
|
||||
:maxlength="100"
|
||||
@input="profileName = $event"
|
||||
@keydown.enter.native="saveProfile"
|
||||
@keydown.enter="saveProfile"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -114,14 +114,14 @@ import { useI18n } from '../../composables/use-i18n-polyfill'
|
||||
import FtCard from '../ft-card/ft-card.vue'
|
||||
import FtPrompt from '../FtPrompt/FtPrompt.vue'
|
||||
import FtFlexBox from '../ft-flex-box/ft-flex-box.vue'
|
||||
import FtInput from '../ft-input/ft-input.vue'
|
||||
import FtInput from '../FtInput/FtInput.vue'
|
||||
import FtButton from '../FtButton/FtButton.vue'
|
||||
|
||||
import store from '../../store/index'
|
||||
|
||||
import { MAIN_PROFILE_ID } from '../../../constants'
|
||||
import { calculateColorLuminance, colors } from '../../helpers/colors'
|
||||
import { showToast } from '../../helpers/utils'
|
||||
import { deepCopy, showToast } from '../../helpers/utils'
|
||||
import { getFirstCharacter } from '../../helpers/strings'
|
||||
|
||||
/**
|
||||
@@ -182,7 +182,7 @@ const translatedProfileName = computed(() => {
|
||||
|
||||
const profileInitial = computed(() => {
|
||||
return profileName.value
|
||||
? getFirstCharacter(translatedProfileName.value, locale.value).toUpperCase()
|
||||
? getFirstCharacter(translatedProfileName.value, locale.value)
|
||||
: ''
|
||||
})
|
||||
|
||||
@@ -204,7 +204,7 @@ function saveProfile() {
|
||||
name: profileName.value,
|
||||
bgColor: profileBgColor.value,
|
||||
textColor: profileTextColor.value,
|
||||
subscriptions: props.profile.subscriptions
|
||||
subscriptions: deepCopy(props.profile.subscriptions)
|
||||
}
|
||||
|
||||
if (!props.isNew) {
|
||||
|
||||
@@ -26,8 +26,8 @@
|
||||
ref="profileListRef"
|
||||
class="profileList"
|
||||
tabindex="-1"
|
||||
@focusout.native="handleProfileListFocusOut"
|
||||
@keydown.native.esc.stop="handleProfileListEscape"
|
||||
@focusout="handleProfileListFocusOut"
|
||||
@keydown.esc.stop="handleProfileListEscape"
|
||||
>
|
||||
<h3
|
||||
:id="id + 'title'"
|
||||
@@ -80,10 +80,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useId } from '../../composables/use-id-polyfill'
|
||||
import { computed, nextTick, ref, useId, useTemplateRef } from 'vue'
|
||||
import { useI18n } from '../../composables/use-i18n-polyfill'
|
||||
import { useRouter } from 'vue-router/composables'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import FtCard from '../ft-card/ft-card.vue'
|
||||
import FtIconButton from '../ft-icon-button/ft-icon-button.vue'
|
||||
@@ -120,7 +119,7 @@ const activeProfile = computed(() => store.getters.getActiveProfile)
|
||||
|
||||
const activeProfileInitial = computed(() => {
|
||||
return activeProfile.value?.name
|
||||
? getFirstCharacter(translateProfileName(activeProfile.value), locale.value).toUpperCase()
|
||||
? getFirstCharacter(translateProfileName(activeProfile.value), locale.value)
|
||||
: ''
|
||||
})
|
||||
|
||||
@@ -130,7 +129,7 @@ const profileInitials = computed(() => {
|
||||
|
||||
return profileList.value.reduce((initials, profile) => {
|
||||
initials[profile._id] = profile?.name
|
||||
? getFirstCharacter(translateProfileName(profile), locale_).toUpperCase()
|
||||
? getFirstCharacter(translateProfileName(profile), locale_)
|
||||
: ''
|
||||
|
||||
return initials
|
||||
@@ -144,7 +143,7 @@ function isActiveProfile(profile) {
|
||||
return profile._id === activeProfile.value._id
|
||||
}
|
||||
|
||||
const profileListRef = ref(null)
|
||||
const profileListRef = useTemplateRef('profileListRef')
|
||||
|
||||
function toggleProfileList() {
|
||||
profileListShown.value = !profileListShown.value
|
||||
@@ -179,8 +178,7 @@ function handleProfileListFocusOut() {
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {import('vue').Ref<HTMLDivElement | null>} */
|
||||
const iconButton = ref(null)
|
||||
const iconButton = useTemplateRef('iconButton')
|
||||
|
||||
function handleProfileListEscape() {
|
||||
iconButton.value?.focus()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<portal to="promptPortal">
|
||||
<Teleport to=".app">
|
||||
<div
|
||||
class="prompt"
|
||||
tabindex="-1"
|
||||
@@ -52,12 +52,11 @@
|
||||
</slot>
|
||||
</FtCard>
|
||||
</div>
|
||||
</portal>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useId } from '../../composables/use-id-polyfill'
|
||||
import { nextTick, onBeforeUnmount, onMounted, useId, useTemplateRef } from 'vue'
|
||||
|
||||
import store from '../../store/index'
|
||||
|
||||
@@ -104,7 +103,7 @@ const emit = defineEmits(['click'])
|
||||
|
||||
const id = useId()
|
||||
|
||||
const promptCard = ref(null)
|
||||
const promptCard = useTemplateRef('promptCard')
|
||||
|
||||
let promptButtons = []
|
||||
let lastActiveElement = null
|
||||
@@ -112,6 +111,7 @@ let lastActiveElement = null
|
||||
onMounted(() => {
|
||||
lastActiveElement = document.activeElement
|
||||
document.addEventListener('keydown', handleEscape, true)
|
||||
store.commit('addOpenPrompt', id)
|
||||
|
||||
nextTick(() => {
|
||||
promptButtons = Array.from(promptCard.value.$el.querySelectorAll('.btn.ripple, .iconButton'))
|
||||
@@ -121,6 +121,7 @@ onMounted(() => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', handleEscape, true)
|
||||
store.commit('removeOpenPrompt', id)
|
||||
nextTick(() => lastActiveElement?.focus())
|
||||
})
|
||||
|
||||
|
||||
@@ -5,20 +5,18 @@
|
||||
</h3>
|
||||
<template
|
||||
v-for="(label, index) in labels"
|
||||
:key="values[index]"
|
||||
>
|
||||
<input
|
||||
:id="id + values[index]"
|
||||
:key="'value' + values[index]"
|
||||
v-model="modelValue"
|
||||
:name="id"
|
||||
:value="values[index]"
|
||||
:checked="value === values[index]"
|
||||
:disabled="disabled"
|
||||
class="radio"
|
||||
type="radio"
|
||||
@change="handleChange(values[index])"
|
||||
>
|
||||
<label
|
||||
:key="'label' + values[index]"
|
||||
:for="id + values[index]"
|
||||
>
|
||||
{{ label }}
|
||||
@@ -28,7 +26,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useId } from '../../composables/use-id-polyfill'
|
||||
import { useId } from 'vue'
|
||||
|
||||
const id = useId()
|
||||
|
||||
@@ -49,27 +47,9 @@ defineProps({
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
// Required for v-model in the parent component (https://v2.vuejs.org/v2/guide/components#Using-v-model-on-Components)
|
||||
// Do not rename or remove
|
||||
// TODO: Replace with defineModel in Vue 3
|
||||
value: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
// Required for v-model in the parent component (https://v2.vuejs.org/v2/guide/components#Using-v-model-on-Components)
|
||||
// Do not rename or remove
|
||||
// TODO: Replace with defineModel in Vue 3
|
||||
const emit = defineEmits(['input'])
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
function handleChange(value) {
|
||||
emit('input', value)
|
||||
}
|
||||
const modelValue = defineModel({ type: String, required: true })
|
||||
</script>
|
||||
|
||||
<style scoped src="./FtRadioButton.css" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.center {
|
||||
margin-block: 5px 10px;
|
||||
margin-block-start: 10px;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -18,6 +18,42 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.titleContainer {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
block-size: inherit;
|
||||
}
|
||||
|
||||
.clearFilterButton {
|
||||
border-radius: 50%;
|
||||
border-style: none;
|
||||
background-color: transparent;
|
||||
color: var(--primary-text-color);
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
line-height: 1em;
|
||||
padding: 10px;
|
||||
transition: background 0.2s ease-out;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--side-nav-hover-color);
|
||||
color: var(--side-nav-hover-text-color);
|
||||
transition: background 0.2s ease-in;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--tertiary-text-color);
|
||||
color: var(--side-nav-active-text-color);
|
||||
transition: background 0.2s ease-in;
|
||||
}
|
||||
}
|
||||
|
||||
.clearFilterIcon {
|
||||
vertical-align: -0.25em;
|
||||
}
|
||||
|
||||
@media only screen and (width <= 680px) {
|
||||
.searchRadio {
|
||||
border-inline-end: 0;
|
||||
|
||||
@@ -4,12 +4,25 @@
|
||||
@click="hideSearchFilters"
|
||||
>
|
||||
<template #label="{ labelId }">
|
||||
<h2
|
||||
:id="labelId"
|
||||
class="center"
|
||||
>
|
||||
{{ title }}
|
||||
</h2>
|
||||
<div class="titleContainer">
|
||||
<h2
|
||||
:id="labelId"
|
||||
class="center"
|
||||
>
|
||||
{{ title }}
|
||||
</h2>
|
||||
<button
|
||||
class="clearFilterButton"
|
||||
:title="$t('Search Filters.Clear Filters')"
|
||||
:style="{visibility: (searchFilterValueChanged ? 'visible' : 'hidden')}"
|
||||
@click="clearFilters"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
class="clearFilterIcon"
|
||||
:icon="['fas', 'filter-circle-xmark']"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<FtFlexBox class="radioFlexBox">
|
||||
@@ -50,12 +63,6 @@
|
||||
/>
|
||||
</FtFlexBox>
|
||||
<div class="searchFilterCloseButtonContainer">
|
||||
<FtButton
|
||||
:label="$t('Search Filters.Clear Filters')"
|
||||
background-color="var(--accent-color)"
|
||||
text-color="var(--text-with-accent-color)"
|
||||
@click="clearFilters"
|
||||
/>
|
||||
<FtButton
|
||||
:label="$t('Close')"
|
||||
background-color="null"
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
<script setup>
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import { useId } from '../../composables/use-id-polyfill'
|
||||
import { useId } from 'vue'
|
||||
|
||||
import FtTooltip from '../FtTooltip/FtTooltip.vue'
|
||||
|
||||
|
||||
@@ -16,14 +16,14 @@
|
||||
background-color: var(--card-bg-color);
|
||||
padding-block: 10px;
|
||||
|
||||
> div {
|
||||
> :deep(div) {
|
||||
box-sizing: border-box;
|
||||
padding-block: 0;
|
||||
padding-inline: 20px;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
> div:not(:last-child, .ft-flex-box) {
|
||||
> :deep(div:not(:last-child, .ft-flex-box)) {
|
||||
@media only screen and (width <= 800px) {
|
||||
margin-block-end: 20px;
|
||||
}
|
||||
@@ -36,10 +36,6 @@
|
||||
margin-block: 0.5em;
|
||||
}
|
||||
|
||||
.dataSettingsBox {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
:deep(.groupTitle) {
|
||||
text-align: center;
|
||||
margin-block: 0.5em;
|
||||
@@ -90,10 +86,8 @@
|
||||
|
||||
@media only screen and (width <= 680px) {
|
||||
.settingsSection {
|
||||
> div {
|
||||
:deep(.text.bottom) {
|
||||
inset-inline-start: -85px;
|
||||
}
|
||||
> :deep(div .text.bottom) {
|
||||
inset-inline-start: -85px;
|
||||
}
|
||||
|
||||
:deep(.switch-ctn.containsTooltip) {
|
||||
@@ -103,16 +97,14 @@
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
:not(.select, .selectLabel) {
|
||||
> :deep(.tooltip) {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
inset-inline-end: -25px;
|
||||
inset-block-start: 12px;
|
||||
}
|
||||
:deep(:not(.select, .selectLabel) > .tooltip) {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
inset-inline-end: -25px;
|
||||
inset-block-start: 12px;
|
||||
}
|
||||
|
||||
.settingsFlexStart460px :deep(.tooltip) {
|
||||
:deep(.settingsFlexStart460px .tooltip) {
|
||||
inset-inline-end: 0;
|
||||
inset-block-start: -2px;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
ref="iconButton"
|
||||
:title="shareTitle"
|
||||
theme="secondary"
|
||||
:size="size"
|
||||
:icon="['fas', 'share-alt']"
|
||||
:dropdown-modal-on-mobile="true"
|
||||
dropdown-position-x="left"
|
||||
@@ -124,7 +125,7 @@
|
||||
|
||||
<script setup>
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { copyToClipboard, openExternalLink } from '../../helpers/utils'
|
||||
import { useI18n } from '../../composables/use-i18n-polyfill'
|
||||
|
||||
@@ -161,11 +162,15 @@ const props = defineProps({
|
||||
dropdownPositionY: {
|
||||
type: String,
|
||||
default: 'bottom'
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
default: 20
|
||||
}
|
||||
})
|
||||
|
||||
const includeTimestamp = ref(false)
|
||||
const iconButton = ref(null)
|
||||
const iconButton = useTemplateRef('iconButton')
|
||||
|
||||
const isChannel = computed(() => {
|
||||
return props.shareTargetType === 'Channel'
|
||||
@@ -175,6 +180,10 @@ const isPlaylist = computed(() => {
|
||||
return props.shareTargetType === 'Playlist'
|
||||
})
|
||||
|
||||
const isPost = computed(() => {
|
||||
return props.shareTargetType === 'Post'
|
||||
})
|
||||
|
||||
const isVideo = computed(() => {
|
||||
return props.shareTargetType === 'Video'
|
||||
})
|
||||
@@ -186,6 +195,9 @@ const shareTitle = computed(() => {
|
||||
if (isPlaylist.value) {
|
||||
return t('Share.Share Playlist')
|
||||
}
|
||||
if (isPost.value) {
|
||||
return t('Share.Share Post')
|
||||
}
|
||||
return t('Share.Share Video')
|
||||
})
|
||||
|
||||
@@ -216,6 +228,9 @@ const invidiousURL = computed(() => {
|
||||
if (isPlaylist.value) {
|
||||
return `${currentInvidiousInstanceUrl.value}/playlist?list=${props.id}`
|
||||
}
|
||||
if (isPost.value) {
|
||||
return `${currentInvidiousInstanceUrl.value}/post/${props.id}`
|
||||
}
|
||||
let videoUrl = `${currentInvidiousInstanceUrl.value}/watch?v=${props.id}`
|
||||
// `playlistId` can be undefined
|
||||
if (playlistSharable.value) {
|
||||
@@ -247,6 +262,9 @@ const youtubeURL = computed(() => {
|
||||
if (isPlaylist.value) {
|
||||
return youtubePlaylistUrl.value
|
||||
}
|
||||
if (isPost.value) {
|
||||
return `https://www.youtube.com/post/${props.id}`
|
||||
}
|
||||
let videoUrl = `https://www.youtube.com/watch?v=${props.id}`
|
||||
if (playlistSharable.value) {
|
||||
// `index` seems can be ignored
|
||||
@@ -262,6 +280,9 @@ const youtubeShareURL = computed(() => {
|
||||
if (isPlaylist.value) {
|
||||
return youtubePlaylistUrl.value
|
||||
}
|
||||
if (isPost.value) {
|
||||
return `https://www.youtube.com/post/${props.id}`
|
||||
}
|
||||
const videoUrl = `https://youtu.be/${props.id}`
|
||||
if (playlistSharable.value) {
|
||||
// `index` seems can be ignored
|
||||
@@ -326,7 +347,7 @@ function updateIncludeTimestamp() {
|
||||
}
|
||||
|
||||
function getFinalUrl(url) {
|
||||
if (isChannel.value || isPlaylist.value) {
|
||||
if (isChannel.value || isPlaylist.value || isPost.value) {
|
||||
return url
|
||||
}
|
||||
if (url.indexOf('?') === -1) {
|
||||
|
||||
@@ -20,8 +20,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useId } from '../../composables/use-id-polyfill.js'
|
||||
import { computed, ref, useId, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
|
||||
@@ -30,8 +30,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useId } from '../../composables/use-id-polyfill'
|
||||
import { computed, useId } from 'vue'
|
||||
import { useI18n } from '../../composables/use-i18n-polyfill'
|
||||
|
||||
import FtSelect from '../FtSelect/FtSelect.vue'
|
||||
|
||||
@@ -90,8 +90,7 @@
|
||||
|
||||
<script setup>
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
import { useId } from '../../composables/use-id-polyfill'
|
||||
import { computed, ref, shallowRef, useId, useTemplateRef } from 'vue'
|
||||
import { useI18n } from '../../composables/use-i18n-polyfill'
|
||||
|
||||
import FtButton from '../FtButton/FtButton.vue'
|
||||
@@ -170,7 +169,7 @@ const profileInitials = computed(() => {
|
||||
|
||||
return profileList.value.reduce((accumulator, profile) => {
|
||||
accumulator[profile._id] = profile.name
|
||||
? getFirstCharacter(profile.name, locale_).toUpperCase()
|
||||
? getFirstCharacter(profile.name, locale_)
|
||||
: ''
|
||||
|
||||
return accumulator
|
||||
@@ -183,7 +182,7 @@ const hideChannelSubscriptions = computed(() => {
|
||||
})
|
||||
|
||||
const subscribedText = computed(() => {
|
||||
let subscribedValue = (isProfileSubscribed(activeProfile.value) ? t('Channel.Unsubscribe') : t('Channel.Subscribe')).toUpperCase()
|
||||
let subscribedValue = (isProfileSubscribed(activeProfile.value) ? t('Channel.Unsubscribe') : t('Channel.Subscribe'))
|
||||
if (props.subscriptionCountText !== '' && !hideChannelSubscriptions.value) {
|
||||
subscribedValue += ' ' + props.subscriptionCountText
|
||||
}
|
||||
@@ -243,10 +242,10 @@ function handleSubscription(profile) {
|
||||
}
|
||||
}
|
||||
|
||||
const subscribeButton = ref(null)
|
||||
const subscribeButton = useTemplateRef('subscribeButton')
|
||||
|
||||
function handleProfileDropdownFocusOut() {
|
||||
if (!subscribeButton.value.matches(':focus-within')) {
|
||||
if (subscribeButton.value && !subscribeButton.value.matches(':focus-within')) {
|
||||
isProfileDropdownOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router/composables'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const props = defineProps({
|
||||
inputHtml: {
|
||||
@@ -21,7 +21,7 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const videoId = router.currentRoute.params.id
|
||||
const videoId = router.currentRoute.value.params.id
|
||||
|
||||
/** @type {import('vue').ComputedRef<string>} */
|
||||
const displayText = computed(() => props.inputHtml.replaceAll(/(?:(\d+):)?(\d+):(\d+)/g, (timestamp, hours, minutes, seconds) => {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, onBeforeUnmount, onMounted, shallowReactive } from 'vue'
|
||||
import { nextTick, onBeforeUnmount, onMounted, reactive } from 'vue'
|
||||
import { ToastEventBus } from '../../helpers/utils'
|
||||
|
||||
let idCounter = 0
|
||||
@@ -34,21 +34,23 @@ let idCounter = 0
|
||||
* @property {number} id
|
||||
*/
|
||||
|
||||
/** @type {import('vue').ShallowReactive<Toast[]>} */
|
||||
const toasts = shallowReactive([])
|
||||
/** @type {import('vue').Reactive<Toast[]>} */
|
||||
const toasts = reactive([])
|
||||
|
||||
/**
|
||||
* @param {CustomEvent<{ message: string | (({elapsedMs: number, remainingMs: number}) => string), time: number | null, action: Function | null, abortSignal: AbortSignal | null }>} event
|
||||
*/
|
||||
function open({ detail: { message, time, action, abortSignal } }) {
|
||||
const id = idCounter++
|
||||
|
||||
/** @type {Toast} */
|
||||
const toast = {
|
||||
id,
|
||||
message,
|
||||
action,
|
||||
isOpen: false,
|
||||
timeout: 0,
|
||||
interval: 0,
|
||||
id: idCounter++
|
||||
interval: 0
|
||||
}
|
||||
time ||= 3000
|
||||
let elapsed = 0
|
||||
@@ -60,7 +62,14 @@ function open({ detail: { message, time, action, abortSignal } }) {
|
||||
elapsed += updateDelay
|
||||
// Skip last update
|
||||
if (elapsed >= time) { return }
|
||||
toast.message = message({ elapsedMs: elapsed, remainingMs: time - elapsed })
|
||||
|
||||
// We need to locate the object in the array so we get the reactive proxy,
|
||||
// as modifying the original object won't trigger reactive effects such as updating the DOM
|
||||
const toast = toasts.find(t => t.id === id)
|
||||
|
||||
if (toast) {
|
||||
toast.message = message({ elapsedMs: elapsed, remainingMs: time - elapsed })
|
||||
}
|
||||
}, updateDelay)
|
||||
}
|
||||
|
||||
@@ -72,7 +81,13 @@ function open({ detail: { message, time, action, abortSignal } }) {
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
toast.isOpen = true
|
||||
// We need to locate the object in the array so we get the reactive proxy,
|
||||
// as modifying the original object won't trigger reactive effects such as updating the DOM
|
||||
const toast = toasts.find(t => t.id === id)
|
||||
|
||||
if (toast) {
|
||||
toast.isOpen = true
|
||||
}
|
||||
})
|
||||
|
||||
if (toasts.length > 4) {
|
||||
|
||||
@@ -79,6 +79,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.containsTooltip .switch-label-text {
|
||||
margin-inline-end: 5px;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
.switch-label {
|
||||
cursor: not-allowed;
|
||||
|
||||
@@ -36,8 +36,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useId } from '../../composables/use-id-polyfill'
|
||||
import { ref, useId, watch } from 'vue'
|
||||
|
||||
import FtTooltip from '../FtTooltip/FtTooltip.vue'
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
<script setup>
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import { useId } from '../../composables/use-id-polyfill'
|
||||
import { useId } from 'vue'
|
||||
|
||||
defineProps({
|
||||
position: {
|
||||
|
||||
@@ -16,16 +16,16 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, useTemplateRef } from 'vue'
|
||||
|
||||
import FtCard from '../ft-card/ft-card.vue'
|
||||
import FtInput from '../ft-input/ft-input.vue'
|
||||
import FtInput from '../FtInput/FtInput.vue'
|
||||
|
||||
import store from '../../store/index'
|
||||
|
||||
const emit = defineEmits(['unlocked'])
|
||||
|
||||
const password = ref(null)
|
||||
const password = useTemplateRef('password')
|
||||
|
||||
onMounted(() => {
|
||||
password.value.focus()
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
input-type="password"
|
||||
:value="password"
|
||||
@input="e => password = e"
|
||||
@keydown.enter.native="handleSetPassword"
|
||||
@keydown.enter="handleSetPassword"
|
||||
/>
|
||||
<FtButton
|
||||
class="centerButton"
|
||||
@@ -37,7 +37,7 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import FtSettingsSection from '../FtSettingsSection/FtSettingsSection.vue'
|
||||
import FtInput from '../ft-input/ft-input.vue'
|
||||
import FtInput from '../FtInput/FtInput.vue'
|
||||
import FtFlexBox from '../ft-flex-box/ft-flex-box.vue'
|
||||
import FtButton from '../FtButton/FtButton.vue'
|
||||
|
||||
|
||||
@@ -264,7 +264,7 @@ import FtToggleSwitch from '../FtToggleSwitch/FtToggleSwitch.vue'
|
||||
import FtSlider from '../FtSlider/FtSlider.vue'
|
||||
import FtFlexBox from '../ft-flex-box/ft-flex-box.vue'
|
||||
import FtButton from '../FtButton/FtButton.vue'
|
||||
import FtInput from '../ft-input/ft-input.vue'
|
||||
import FtInput from '../FtInput/FtInput.vue'
|
||||
import FtTooltip from '../FtTooltip/FtTooltip.vue'
|
||||
|
||||
import store from '../../store/index'
|
||||
|
||||
@@ -27,6 +27,18 @@
|
||||
|
||||
.playlistTitle {
|
||||
margin-block-end: 0.1em;
|
||||
|
||||
/* Prevents overflow for long values */
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
@media only screen and (width <= 850px) {
|
||||
// margin-inline-in routerView = 8px x2 = 16px
|
||||
// For unknown reason class `routerView` is in 2 containers
|
||||
// padding for `playlistInfo` is 10px x2 = 20px
|
||||
// Also scrollbar got unknown width so using 95vw instead of 100vw
|
||||
max-inline-size: calc(95vw - 32px - 20px);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.playlistDescription {
|
||||
@@ -99,7 +111,6 @@
|
||||
.playlistDescription {
|
||||
overflow-x: hidden;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
max-inline-size: 750px;
|
||||
inline-size: 100%;
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
:value="newTitle"
|
||||
:maxlength="255"
|
||||
@input="handlePlaylistNameInput"
|
||||
@keydown.enter.native="savePlaylistInfo"
|
||||
@keydown.enter="savePlaylistInfo"
|
||||
/>
|
||||
<FtFlexBox v-if="inputPlaylistNameBlank">
|
||||
<p>
|
||||
@@ -67,14 +67,14 @@
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p>
|
||||
{{ $tc('Global.Counts.Video Count', videoCount, { count: parsedVideoCount }) }}
|
||||
<span v-if="!hideViews && !isUserPlaylist">
|
||||
- {{ $tc('Global.Counts.View Count', viewCount, { count: parsedViewCount }) }}
|
||||
</span>
|
||||
<span>- </span>
|
||||
<span v-if="infoSource !== 'local'">
|
||||
{{ t('Global.Counts.Video Count', { count: parsedVideoCount }, videoCount) }}
|
||||
<template v-if="!hideViews && !isUserPlaylist">
|
||||
- {{ t('Global.Counts.View Count', { count: parsedViewCount }, viewCount) }}
|
||||
</template>
|
||||
-
|
||||
<template v-if="infoSource !== 'local'">
|
||||
{{ $t("Playlist.Last Updated On") }}
|
||||
</span>
|
||||
</template>
|
||||
{{ lastUpdated }}
|
||||
<template v-if="durationFormatted !== ''">
|
||||
<br>
|
||||
@@ -92,7 +92,7 @@
|
||||
:show-label="false"
|
||||
:value="newDescription"
|
||||
@input="(input) => newDescription = input"
|
||||
@keydown.enter.native="savePlaylistInfo"
|
||||
@keydown.enter="savePlaylistInfo"
|
||||
/>
|
||||
<p
|
||||
v-else
|
||||
@@ -263,13 +263,13 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from '../../composables/use-i18n-polyfill'
|
||||
import { useRouter } from 'vue-router/composables'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import FtFlexBox from '../ft-flex-box/ft-flex-box.vue'
|
||||
import FtIconButton from '../ft-icon-button/ft-icon-button.vue'
|
||||
import FtInput from '../ft-input/ft-input.vue'
|
||||
import FtInput from '../FtInput/FtInput.vue'
|
||||
import FtPrompt from '../FtPrompt/FtPrompt.vue'
|
||||
import FtShareButton from '../FtShareButton/FtShareButton.vue'
|
||||
|
||||
@@ -282,6 +282,7 @@ import {
|
||||
showToast,
|
||||
getTodayDateStrLocalTimezone,
|
||||
writeFileWithPicker,
|
||||
deepCopy,
|
||||
} from '../../helpers/utils'
|
||||
import thumbnailPlaceholder from '../../assets/img/thumbnail_placeholder.svg'
|
||||
|
||||
@@ -481,7 +482,7 @@ const userPlaylistAnyVideoWatched = computed(() => {
|
||||
|
||||
const historyCacheById_ = historyCacheById.value
|
||||
return selectedUserPlaylist.value.videos.some((video) => {
|
||||
return Object.hasOwn(historyCacheById_, video.videoId)
|
||||
return historyCacheById_[video.videoId] !== undefined
|
||||
})
|
||||
})
|
||||
|
||||
@@ -594,7 +595,7 @@ async function savePlaylistInfo() {
|
||||
playlistName: newTitle.value,
|
||||
protected: selectedUserPlaylist.value.protected,
|
||||
description: newDescription.value,
|
||||
videos: selectedUserPlaylist.value.videos,
|
||||
videos: deepCopy(selectedUserPlaylist.value.videos),
|
||||
_id: props.id,
|
||||
}
|
||||
try {
|
||||
@@ -608,7 +609,7 @@ async function savePlaylistInfo() {
|
||||
}
|
||||
}
|
||||
|
||||
const playlistTitleInput = ref(null)
|
||||
const playlistTitleInput = useTemplateRef('playlistTitleInput')
|
||||
|
||||
function enterEditMode() {
|
||||
newTitle.value = props.title
|
||||
@@ -740,7 +741,7 @@ const userPlaylistWatchedVideoCount = computed(() => {
|
||||
|
||||
const historyCacheById_ = historyCacheById.value
|
||||
return selectedUserPlaylist.value.videos.reduce((count, video) => {
|
||||
return Object.hasOwn(historyCacheById_, video.videoId) ? count + 1 : count
|
||||
return historyCacheById_[video.videoId] !== undefined ? count + 1 : count
|
||||
}, 0)
|
||||
})
|
||||
|
||||
@@ -787,7 +788,7 @@ async function handleRemoveDuplicateVideosPromptAnswer(option) {
|
||||
playlistName: props.title,
|
||||
protected: selectedUserPlaylist.value.protected,
|
||||
description: props.description,
|
||||
videos: newVideoItems,
|
||||
videos: deepCopy(newVideoItems),
|
||||
_id: props.id,
|
||||
}
|
||||
try {
|
||||
@@ -824,7 +825,7 @@ async function handleRemoveVideosOnWatchPromptAnswer(option) {
|
||||
playlistName: props.title,
|
||||
protected: selectedUserPlaylist.value.protected,
|
||||
description: props.description,
|
||||
videos: videosToWatch,
|
||||
videos: deepCopy(videosToWatch),
|
||||
_id: props.id
|
||||
}
|
||||
try {
|
||||
@@ -892,7 +893,7 @@ const updateQueryDebounced = debounce((newQuery) => {
|
||||
emit('search-video-query-change', newQuery)
|
||||
}, 500)
|
||||
|
||||
const searchInput = ref(null)
|
||||
const searchInput = useTemplateRef('searchInput')
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} event
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
show-label
|
||||
:value="proxyHostname"
|
||||
@input="handleUpdateProxyHostname"
|
||||
@keydown.enter.native="testProxy"
|
||||
@keydown.enter="testProxy"
|
||||
/>
|
||||
<FtInput
|
||||
:placeholder="$t('Settings.Proxy Settings.Proxy Port Number')"
|
||||
@@ -50,7 +50,7 @@
|
||||
:value="proxyPort"
|
||||
:maxlength="5"
|
||||
@input="handleUpdateProxyPort"
|
||||
@keydown.enter.native="testProxy"
|
||||
@keydown.enter="testProxy"
|
||||
/>
|
||||
</FtFlexBox>
|
||||
<FtFlexBox>
|
||||
@@ -60,7 +60,7 @@
|
||||
show-label
|
||||
:value="proxyUsername"
|
||||
@input="handleUpdateProxyUsername"
|
||||
@keydown.enter.native="testProxy"
|
||||
@keydown.enter="testProxy"
|
||||
/>
|
||||
<FtInput
|
||||
:placeholder="$t('Settings.Proxy Settings.Proxy Password')"
|
||||
@@ -69,7 +69,7 @@
|
||||
:value="proxyPassword"
|
||||
input-type="password"
|
||||
@input="handleUpdateProxyPassword"
|
||||
@keydown.enter.native="testProxy"
|
||||
@keydown.enter="testProxy"
|
||||
/>
|
||||
</FtFlexBox>
|
||||
<p
|
||||
@@ -119,7 +119,7 @@ import FtSettingsSection from '../FtSettingsSection/FtSettingsSection.vue'
|
||||
import FtToggleSwitch from '../FtToggleSwitch/FtToggleSwitch.vue'
|
||||
import FtButton from '../FtButton/FtButton.vue'
|
||||
import FtSelect from '../FtSelect/FtSelect.vue'
|
||||
import FtInput from '../ft-input/ft-input.vue'
|
||||
import FtInput from '../FtInput/FtInput.vue'
|
||||
import FtLoader from '../FtLoader/FtLoader.vue'
|
||||
import FtFlexBox from '../ft-flex-box/ft-flex-box.vue'
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
</p>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="!hideTrendingVideos"
|
||||
v-if="SUPPORTS_LOCAL_API && !hideTrendingVideos && (backendFallback || backendPreference === 'local')"
|
||||
class="navOption mobileHidden"
|
||||
role="button"
|
||||
to="/trending"
|
||||
@@ -200,6 +200,7 @@
|
||||
<hr>
|
||||
<div
|
||||
v-if="!hideActiveSubscriptions"
|
||||
class="mobileHidden"
|
||||
>
|
||||
<router-link
|
||||
v-for="channel in activeSubscriptions"
|
||||
@@ -255,6 +256,8 @@ import { KeyboardShortcuts } from '../../../constants'
|
||||
|
||||
const { locale, t } = useI18n()
|
||||
|
||||
const SUPPORTS_LOCAL_API = process.env.SUPPORTS_LOCAL_API
|
||||
|
||||
/** @type {import('vue').ComputedRef<boolean>} */
|
||||
const isOpen = computed(() => {
|
||||
return store.getters.getIsSideNavOpen
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
</p>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="!hideTrendingVideos"
|
||||
v-if=" SUPPORTS_LOCAL_API && trendingVisible"
|
||||
class="navOption"
|
||||
:title="$t('Trending.Trending')"
|
||||
:aria-label="hideLabelsSideBar ? $t('Trending.Trending') : null"
|
||||
@@ -197,18 +197,21 @@
|
||||
|
||||
<script setup>
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import router from '../../router/index.js'
|
||||
import { computed, ref, onMounted, onBeforeUnmount, useTemplateRef } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import store from '../../store/index'
|
||||
|
||||
const SUPPORTS_LOCAL_API = process.env.SUPPORTS_LOCAL_API
|
||||
|
||||
const openMoreOptions = ref(false)
|
||||
|
||||
const menuRef = ref(null)
|
||||
const menuRef = useTemplateRef('menuRef')
|
||||
|
||||
/** @type {import('vue').ComputedRef<boolean>} */
|
||||
const hideTrendingVideos = computed(() => {
|
||||
return store.getters.getHideTrendingVideos
|
||||
const trendingVisible = computed(() => {
|
||||
return !store.getters.getHideTrendingVideos &&
|
||||
(store.getters.getBackendFallback || store.getters.getBackendPreference === 'local')
|
||||
})
|
||||
|
||||
/** @type {import('vue').ComputedRef<boolean>} */
|
||||
@@ -238,6 +241,8 @@ function handleClickOutside(event) {
|
||||
}
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
router.afterEach(() => {
|
||||
|
||||
@@ -74,7 +74,7 @@ import { computed } from 'vue'
|
||||
|
||||
import FtSettingsSection from './FtSettingsSection/FtSettingsSection.vue'
|
||||
import FtToggleSwitch from './FtToggleSwitch/FtToggleSwitch.vue'
|
||||
import FtInput from './ft-input/ft-input.vue'
|
||||
import FtInput from './FtInput/FtInput.vue'
|
||||
import FtFlexBox from './ft-flex-box/ft-flex-box.vue'
|
||||
import FtSponsorBlockCategory from './FtSponsorBlockCategory/FtSponsorBlockCategory.vue'
|
||||
|
||||
|
||||
@@ -155,6 +155,11 @@ function loadPostsFromCacheSometimes() {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
/** @type {import('vue').ComputedRef<string[]>} */
|
||||
const forbiddenTitles = computed(() => {
|
||||
return JSON.parse(store.getters.getForbiddenTitles.toLowerCase())
|
||||
})
|
||||
|
||||
function loadPostsFromCacheForAllActiveProfileChannels() {
|
||||
const postList_ = cacheEntriesForAllActiveProfileChannels.value.flatMap((cacheEntry) => {
|
||||
return cacheEntry.posts
|
||||
@@ -164,7 +169,7 @@ function loadPostsFromCacheForAllActiveProfileChannels() {
|
||||
return b.publishedTime - a.publishedTime
|
||||
})
|
||||
|
||||
postList.value = postList_
|
||||
postList.value = postList_.filter(post => !forbiddenTitles.value.some(text => post.author.toLowerCase().includes(text)))
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
@@ -224,6 +229,7 @@ async function loadPostsForSubscriptionsFromRemote() {
|
||||
}
|
||||
}
|
||||
|
||||
posts = posts.filter(post => !forbiddenTitles.value.some(text => post.author.toLowerCase().includes(text)))
|
||||
return posts
|
||||
}))).flat()
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ const hideWatchedSubs = computed(() => {
|
||||
const filteredVideoList = computed(() => {
|
||||
if (hideWatchedSubs.value && !props.isCommunity) {
|
||||
return props.videoList.filter((video) => {
|
||||
return !Object.hasOwn(historyCacheById.value, video.videoId)
|
||||
return historyCacheById.value[video.videoId] === undefined
|
||||
})
|
||||
} else {
|
||||
return props.videoList
|
||||
|
||||
@@ -128,6 +128,7 @@ const BASE_THEME_VALUES = [
|
||||
'pastelPink',
|
||||
// Third group
|
||||
'catppuccinFrappe',
|
||||
'catppuccinLatte',
|
||||
'catppuccinMocha',
|
||||
'dracula',
|
||||
'everforestDarkHard',
|
||||
@@ -154,6 +155,7 @@ const baseThemeNames = computed(() => [
|
||||
t('Settings.Theme Settings.Base Theme.Pastel Pink'),
|
||||
// Third group
|
||||
t('Settings.Theme Settings.Base Theme.Catppuccin Frappe'),
|
||||
t('Settings.Theme Settings.Base Theme.Catppuccin Latte'),
|
||||
t('Settings.Theme Settings.Base Theme.Catppuccin Mocha'),
|
||||
t('Settings.Theme Settings.Base Theme.Dracula'),
|
||||
t('Settings.Theme Settings.Base Theme.Everforest Dark Hard'),
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.menuIcon {
|
||||
.menuButton {
|
||||
@media only screen and (width <= 680px) {
|
||||
display: none;
|
||||
}
|
||||
@@ -54,22 +54,24 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.navIcon,
|
||||
:deep(.ftIconButton:not(.arrowDisabled) .iconButton) {
|
||||
.navButton {
|
||||
border-radius: 50%;
|
||||
border-style: none;
|
||||
background-color: transparent;
|
||||
color: var(--primary-text-color);
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
block-size: 1em;
|
||||
line-height: 1em;
|
||||
padding: 10px;
|
||||
transition: background 0.2s ease-out;
|
||||
inline-size: 1em;
|
||||
}
|
||||
|
||||
.navButton,
|
||||
:deep(.ftIconButton:not(.arrowDisabled) .iconButton) {
|
||||
&:hover {
|
||||
background-color: var(--side-nav-hover-color);
|
||||
color: var(--side-nav-hover-text-color);
|
||||
transition: background 0.2s ease-in;
|
||||
|
||||
}
|
||||
|
||||
&:active {
|
||||
@@ -79,7 +81,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.topNavBarColor .navIcon,
|
||||
.navIcon {
|
||||
inline-size: 1em;
|
||||
}
|
||||
|
||||
.topNavBarColor .navButton,
|
||||
.topNavBarColor :deep(.ftIconButton:not(.arrowDisabled) .iconButton) {
|
||||
color: var(--text-with-main-color);
|
||||
|
||||
@@ -93,7 +99,7 @@
|
||||
}
|
||||
|
||||
|
||||
.navFilterIcon {
|
||||
.navFilterButton {
|
||||
$effect-distance: 20px;
|
||||
|
||||
margin-inline-start: $effect-distance;
|
||||
@@ -121,13 +127,13 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.navSearchIcon {
|
||||
.navSearchButton {
|
||||
@media only screen and (width >= 681px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.navNewWindowIcon {
|
||||
.navNewWindowButton {
|
||||
@media only screen and (width <= 680px) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -5,14 +5,15 @@
|
||||
role="navigation"
|
||||
>
|
||||
<div class="side">
|
||||
<FontAwesomeIcon
|
||||
class="menuIcon navIcon"
|
||||
:icon="['fas', 'bars']"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
<button
|
||||
class="menuButton navButton"
|
||||
@click="toggleSideNav"
|
||||
@keydown.enter.prevent="toggleSideNav"
|
||||
/>
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
class="navIcon"
|
||||
:icon="['fas', 'bars']"
|
||||
/>
|
||||
</button>
|
||||
<FtIconButton
|
||||
class="navIconButton"
|
||||
:disabled="isArrowBackwardDisabled"
|
||||
@@ -26,7 +27,6 @@
|
||||
open-on-right-or-long-click
|
||||
:title="backwardText"
|
||||
@click="historyBack"
|
||||
@keydown.enter.prevent="historyBack"
|
||||
/>
|
||||
<FtIconButton
|
||||
class="navIconButton"
|
||||
@@ -41,26 +41,28 @@
|
||||
open-on-right-or-long-click
|
||||
:title="forwardText"
|
||||
@click="historyForward"
|
||||
@keydown.enter.prevent="historyForward"
|
||||
/>
|
||||
<FontAwesomeIcon
|
||||
<button
|
||||
v-if="!hideSearchBar"
|
||||
class="navSearchIcon navIcon"
|
||||
:icon="['fas', 'search']"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="navSearchButton navButton"
|
||||
@click="toggleSearchContainer"
|
||||
@keydown.enter.prevent="toggleSearchContainer"
|
||||
/>
|
||||
<FontAwesomeIcon
|
||||
class="navNewWindowIcon navIcon"
|
||||
:icon="['fas', 'clone']"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
class="navIcon"
|
||||
:icon="['fas', 'search']"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="navNewWindowButton navButton"
|
||||
:aria-label="t('Open New Window')"
|
||||
:title="newWindowText"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="createNewWindow"
|
||||
@keydown.enter.prevent="createNewWindow"
|
||||
/>
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
class="navIcon"
|
||||
:icon="['fas', 'clone']"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
v-if="!hideHeaderLogo"
|
||||
class="logo"
|
||||
@@ -89,7 +91,7 @@
|
||||
>
|
||||
<FtInput
|
||||
ref="searchInput"
|
||||
:placeholder="$t('Search / Go to URL')"
|
||||
:placeholder="t('Search / Go to URL')"
|
||||
class="searchInput"
|
||||
is-search
|
||||
:data-list="activeDataList"
|
||||
@@ -101,16 +103,18 @@
|
||||
@clear="clearLastSuggestionQuery"
|
||||
@remove="removeSearchHistoryEntryInDbAndCache"
|
||||
/>
|
||||
<FontAwesomeIcon
|
||||
class="navFilterIcon navIcon"
|
||||
<button
|
||||
class="navFilterButton navButton"
|
||||
:class="{ filterChanged: searchFilterValueChanged }"
|
||||
:icon="['fas', 'filter']"
|
||||
:title="$t('Search Filters.Search Filters')"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="t('Search Filters.Search Filters')"
|
||||
:title="t('Search Filters.Search Filters')"
|
||||
@click="showSearchFilters"
|
||||
@keydown.enter.prevent="showSearchFilters"
|
||||
/>
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
class="navIcon"
|
||||
:icon="['fas', 'filter']"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<FtProfileSelector class="side profiles" />
|
||||
@@ -119,11 +123,11 @@
|
||||
|
||||
<script setup>
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from '../../composables/use-i18n-polyfill'
|
||||
import { useRoute, useRouter } from 'vue-router/composables'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import FtInput from '../ft-input/ft-input.vue'
|
||||
import FtInput from '../FtInput/FtInput.vue'
|
||||
import FtProfileSelector from '../FtProfileSelector/FtProfileSelector.vue'
|
||||
import FtIconButton from '../ft-icon-button/ft-icon-button.vue'
|
||||
|
||||
@@ -365,10 +369,8 @@ function showSearchFilters() {
|
||||
store.dispatch('showSearchFilters')
|
||||
}
|
||||
|
||||
/** @type {import('vue').Ref<HTMLDivElement | null>} */
|
||||
const searchContainer = ref(null)
|
||||
/** @type {import('vue').Ref<InstanceType<typeof FtInput> | null>} */
|
||||
const searchInput = ref(null)
|
||||
const searchContainer = useTemplateRef('searchContainer')
|
||||
const searchInput = useTemplateRef('searchInput')
|
||||
|
||||
/** @type {import('vue').ComputedRef<any>} */
|
||||
const searchSettings = computed(() => store.getters.getSearchSettings)
|
||||
@@ -493,7 +495,8 @@ function goToSearch(queryText, { event }) {
|
||||
time: searchSettings.value.time,
|
||||
type: searchSettings.value.type,
|
||||
duration: searchSettings.value.duration,
|
||||
features: searchSettings.value.features,
|
||||
// Array proxy cannot be cloned during IPC call
|
||||
features: [...searchSettings.value.features],
|
||||
},
|
||||
doCreateNewWindow,
|
||||
searchQueryText: queryText,
|
||||
@@ -516,7 +519,7 @@ function clearLastSuggestionQuery() {
|
||||
* @param {string} text
|
||||
*/
|
||||
function updateSearchInputText(text) {
|
||||
searchInput.value?.updateInputData(text)
|
||||
searchInput.value?.setText(text)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -32,7 +32,6 @@
|
||||
<div
|
||||
v-for="(chapter, index) in chapters"
|
||||
:key="index"
|
||||
:ref="index === currentIndex ? 'currentChaptersItem' : null"
|
||||
class="chapter"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@@ -65,7 +64,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
|
||||
import FtCard from '../ft-card/ft-card.vue'
|
||||
@@ -87,11 +86,7 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['timestamp-event'])
|
||||
|
||||
/** @type {import('vue').Ref<HTMLDivElement | null>} */
|
||||
const chaptersWrapper = ref(null)
|
||||
|
||||
/** @type {import('vue').Ref<HTMLDivElement[]>} */
|
||||
const currentChaptersItem = ref([])
|
||||
const chaptersWrapper = useTemplateRef('chaptersWrapper')
|
||||
|
||||
let chaptersVisible = false
|
||||
const currentIndex = ref(props.currentChapterIndex)
|
||||
@@ -181,9 +176,9 @@ function chaptersToggled(event) {
|
||||
|
||||
function scrollToCurrentChapter() {
|
||||
const container = chaptersWrapper.value
|
||||
const currentItem = currentChaptersItem.value[0]
|
||||
const currentItem = container ? Array.from(container.children)[currentIndex.value] : null
|
||||
|
||||
if (container != null && currentItem != null) {
|
||||
if (currentItem != null) {
|
||||
container.scrollTop = currentItem.offsetTop - container.offsetTop
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
:input-html="processedShownDescription"
|
||||
:link-tab-index="linkTabIndex"
|
||||
@timestamp-event="onTimestamp"
|
||||
@click.native="expandDescriptionWithClick"
|
||||
@click="expandDescriptionWithClick"
|
||||
/>
|
||||
<span
|
||||
v-if="license && showFullDescription"
|
||||
@@ -45,7 +45,7 @@
|
||||
<script setup>
|
||||
import autolinker from 'autolinker'
|
||||
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { onMounted, ref, computed, useTemplateRef } from 'vue'
|
||||
import FtCard from '../ft-card/ft-card.vue'
|
||||
import FtTimestampCatcher from '../FtTimestampCatcher.vue'
|
||||
|
||||
@@ -67,7 +67,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(['timestamp-event'])
|
||||
|
||||
let shownDescription = ''
|
||||
const descriptionContainer = ref()
|
||||
const descriptionContainer = useTemplateRef('descriptionContainer')
|
||||
const showFullDescription = ref(false)
|
||||
const showControls = ref(false)
|
||||
|
||||
|
||||
570
src/renderer/components/WatchVideoInfo/WatchVideoInfo.vue
Normal file
570
src/renderer/components/WatchVideoInfo/WatchVideoInfo.vue
Normal file
@@ -0,0 +1,570 @@
|
||||
<template>
|
||||
<FtCard class="watchVideoInfo">
|
||||
<div>
|
||||
<h1
|
||||
class="videoTitle"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<div
|
||||
v-if="isUnlisted"
|
||||
class="unlistedBadge"
|
||||
>
|
||||
{{ t('Video.Unlisted') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="videoMetrics">
|
||||
<div class="datePublishedAndViewCount">
|
||||
{{ publishedString }} {{ dateString }}
|
||||
<template
|
||||
v-if="!hideVideoViews"
|
||||
>
|
||||
<span class="seperator">• </span><span class="videoViews">{{ parsedViewCount }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="!hideVideoLikesAndDislikes"
|
||||
class="likeBarContainer"
|
||||
>
|
||||
<div
|
||||
class="likeSection"
|
||||
>
|
||||
<span class="likeCount"><FontAwesomeIcon :icon="['fas', 'thumbs-up']" /> {{ parsedLikeCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="videoButtons">
|
||||
<div
|
||||
class="profileRow"
|
||||
>
|
||||
<div
|
||||
v-if="!hideUploader"
|
||||
>
|
||||
<RouterLink
|
||||
:to="`/channel/${channelId}`"
|
||||
>
|
||||
<img
|
||||
:src="channelThumbnail"
|
||||
class="channelThumbnail"
|
||||
alt=""
|
||||
>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
v-if="!hideUploader"
|
||||
>
|
||||
<RouterLink
|
||||
:to="`/channel/${channelId}`"
|
||||
class="channelName"
|
||||
>
|
||||
{{ channelName }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
<FtSubscribeButton
|
||||
v-if="!hideUnsubscribeButton"
|
||||
:channel-id="channelId"
|
||||
:channel-name="channelName"
|
||||
:channel-thumbnail="channelThumbnail"
|
||||
:subscription-count-text="subscriptionCountText"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="videoOptions">
|
||||
<span class="videoOptionsMobileRow">
|
||||
<FtIconButton
|
||||
v-if="showPlaylists && !isUpcoming"
|
||||
:title="t('User Playlists.Add to Playlist')"
|
||||
:icon="['fas', 'plus']"
|
||||
theme="base"
|
||||
@click="togglePlaylistPrompt"
|
||||
/>
|
||||
<FtIconButton
|
||||
v-if="isQuickBookmarkEnabled"
|
||||
:title="quickBookmarkIconText"
|
||||
:icon="isInQuickBookmarkPlaylist ? ['fas', 'check'] : ['fas', 'bookmark']"
|
||||
class="quickBookmarkVideoIcon"
|
||||
:class="{
|
||||
bookmarked: isInQuickBookmarkPlaylist,
|
||||
}"
|
||||
:theme="quickBookmarkIconTheme"
|
||||
@click="toggleQuickBookmarked"
|
||||
/>
|
||||
<FtIconButton
|
||||
v-if="canSaveWatchedProgress && watchedProgressSavingInSemiAutoMode"
|
||||
:title="t('Video.Save Watched Progress')"
|
||||
:icon="['fas', 'bars-progress']"
|
||||
@click="saveWatchedProgressManually"
|
||||
/>
|
||||
</span>
|
||||
<span class="videoOptionsMobileRow">
|
||||
<FtIconButton
|
||||
v-if="USING_ELECTRON && externalPlayer !== ''"
|
||||
:title="t('Video.External Player.OpenInTemplate', { externalPlayer })"
|
||||
:icon="['fas', 'external-link-alt']"
|
||||
theme="secondary"
|
||||
@click="handleExternalPlayer"
|
||||
/>
|
||||
<FtIconButton
|
||||
v-if="!isUpcoming && downloadLinks.length > 0"
|
||||
ref="downloadButton"
|
||||
:title="t('Video.Download Video')"
|
||||
theme="secondary"
|
||||
:icon="['fas', 'download']"
|
||||
:return-index="true"
|
||||
:dropdown-options="downloadLinks"
|
||||
@click="handleDownload"
|
||||
/>
|
||||
<FtIconButton
|
||||
v-if="!isUpcoming"
|
||||
:title="t('Change Format.Change Media Formats')"
|
||||
theme="secondary"
|
||||
:icon="['fas', 'file-video']"
|
||||
:dropdown-options="formatTypeOptions"
|
||||
@click="changeFormat"
|
||||
/>
|
||||
<FtShareButton
|
||||
v-if="!hideSharingActions"
|
||||
:id="id"
|
||||
:get-timestamp="getTimestamp"
|
||||
:playlist-id="playlistId"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</FtCard>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from '../../composables/use-i18n-polyfill'
|
||||
|
||||
import FtCard from '../ft-card/ft-card.vue'
|
||||
import FtIconButton from '../ft-icon-button/ft-icon-button.vue'
|
||||
import FtShareButton from '../FtShareButton/FtShareButton.vue'
|
||||
import FtSubscribeButton from '../FtSubscribeButton/FtSubscribeButton.vue'
|
||||
|
||||
import store from '../../store'
|
||||
|
||||
import { formatNumber, openExternalLink, showToast } from '../../helpers/utils'
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
channelId: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
channelName: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
channelThumbnail: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
published: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
premiereDate: {
|
||||
type: Date,
|
||||
default: undefined
|
||||
},
|
||||
viewCount: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
subscriptionCountText: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
likeCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
dislikeCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
getTimestamp: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
isLive: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
},
|
||||
isLiveContent: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
isUpcoming: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
downloadLinks: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
playlistId: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
getPlaylistIndex: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getPlaylistReverse: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getPlaylistShuffle: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getPlaylistLoop: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
lengthSeconds: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
videoThumbnail: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
inUserPlaylist: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
isUnlisted: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
},
|
||||
canSaveWatchedProgress: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'change-format',
|
||||
'pause-player',
|
||||
'set-info-area-sticky',
|
||||
'scroll-to-info-area',
|
||||
'save-watched-progress',
|
||||
])
|
||||
|
||||
const USING_ELECTRON = process.env.IS_ELECTRON
|
||||
|
||||
const { locale, t } = useI18n()
|
||||
|
||||
/** @type {import('vue').ComputedRef<boolean>} */
|
||||
const hideSharingActions = computed(() => store.getters.getHideSharingActions)
|
||||
|
||||
/** @type {import('vue').ComputedRef<boolean>} */
|
||||
const hideUnsubscribeButton = computed(() => store.getters.getHideUnsubscribeButton)
|
||||
|
||||
/** @type {import('vue').ComputedRef<boolean>} */
|
||||
const hideUploader = computed(() => store.getters.getHideUploader)
|
||||
|
||||
/** @type {import('vue').ComputedRef<boolean>} */
|
||||
const hideVideoLikesAndDislikes = computed(() => store.getters.getHideVideoLikesAndDislikes)
|
||||
|
||||
const parsedLikeCount = computed(() => {
|
||||
if (hideVideoLikesAndDislikes.value || props.likeCount === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return formatNumber(props.likeCount)
|
||||
})
|
||||
|
||||
/** @type {import('vue').ComputedRef<boolean>} */
|
||||
const hideVideoViews = computed(() => store.getters.getHideVideoViews)
|
||||
|
||||
const parsedViewCount = computed(() => {
|
||||
if (hideVideoViews.value || props.viewCount == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return t('Global.Counts.View Count', { count: formatNumber(props.viewCount) }, props.viewCount)
|
||||
})
|
||||
|
||||
const dateString = computed(() => {
|
||||
const formatter = new Intl.DateTimeFormat([locale.value, 'en'], { dateStyle: 'medium' })
|
||||
const localeDateString = formatter.format(props.published)
|
||||
// replace spaces with no break spaces to make the date act as a single entity while wrapping
|
||||
return localeDateString.replaceAll(' ', '\u00A0')
|
||||
})
|
||||
|
||||
const publishedString = computed(() => {
|
||||
if (props.isLive) {
|
||||
return t('Video.Started streaming on')
|
||||
} else if (props.isLiveContent && !props.isLive) {
|
||||
return t('Video.Streamed on')
|
||||
} else {
|
||||
return t('Video.Published on')
|
||||
}
|
||||
})
|
||||
|
||||
const formatTypeOptions = computed(() => [
|
||||
{
|
||||
label: t('Change Format.Use Dash Formats'),
|
||||
value: 'dash'
|
||||
},
|
||||
{
|
||||
label: t('Change Format.Use Legacy Formats'),
|
||||
value: 'legacy'
|
||||
},
|
||||
{
|
||||
label: t('Change Format.Use Audio Formats'),
|
||||
value: 'audio'
|
||||
}
|
||||
])
|
||||
|
||||
/**
|
||||
* @param {'dash' | 'legacy' | 'audio'} value
|
||||
*/
|
||||
function changeFormat(value) {
|
||||
emit('change-format', value)
|
||||
}
|
||||
|
||||
const watchedProgressSavingInSemiAutoMode = computed(() => {
|
||||
return store.getters.getWatchedProgressSavingMode === 'semi-auto'
|
||||
})
|
||||
|
||||
function saveWatchedProgressManually() {
|
||||
emit('save-watched-progress')
|
||||
}
|
||||
|
||||
/** @type {import('vue').ComputedRef<boolean>} */
|
||||
const rememberHistory = computed(() => store.getters.getRememberHistory)
|
||||
|
||||
const historyEntryExists = computed(() => store.getters.getHistoryCacheById[props.id] !== undefined)
|
||||
|
||||
/** @type {import('vue').ComputedRef<string>} */
|
||||
const externalPlayer = computed(() => store.getters.getExternalPlayer)
|
||||
|
||||
/** @type {import('vue').ComputedRef<number>} */
|
||||
const defaultPlayback = computed(() => store.getters.getDefaultPlayback)
|
||||
|
||||
function handleExternalPlayer() {
|
||||
emit('pause-player')
|
||||
|
||||
let payload
|
||||
|
||||
// Only play video in non playlist mode when user playlist detected
|
||||
if (props.inUserPlaylist) {
|
||||
payload = {
|
||||
watchProgress: props.getTimestamp(),
|
||||
playbackRate: defaultPlayback.value,
|
||||
videoId: props.id,
|
||||
videoLength: props.lengthSeconds
|
||||
}
|
||||
} else {
|
||||
payload = {
|
||||
watchProgress: props.getTimestamp(),
|
||||
playbackRate: defaultPlayback.value,
|
||||
videoId: props.id,
|
||||
videoLength: props.lengthSeconds,
|
||||
playlistId: props.playlistId,
|
||||
playlistIndex: props.getPlaylistIndex(),
|
||||
playlistReverse: props.getPlaylistReverse(),
|
||||
playlistShuffle: props.getPlaylistShuffle(),
|
||||
playlistLoop: props.getPlaylistLoop()
|
||||
}
|
||||
}
|
||||
|
||||
store.dispatch('openInExternalPlayer', payload)
|
||||
|
||||
if (rememberHistory.value) {
|
||||
// Marking as watched
|
||||
const videoData = {
|
||||
videoId: props.id,
|
||||
title: props.title,
|
||||
author: props.channelName,
|
||||
authorId: props.channelId,
|
||||
published: props.published,
|
||||
description: props.description,
|
||||
viewCount: props.viewCount,
|
||||
lengthSeconds: props.lengthSeconds,
|
||||
watchProgress: 0,
|
||||
timeWatched: Date.now(),
|
||||
isLive: false,
|
||||
type: 'video'
|
||||
}
|
||||
|
||||
store.dispatch('updateHistory', videoData)
|
||||
|
||||
if (!historyEntryExists.value) {
|
||||
showToast(t('Video.Video has been marked as watched'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const downloadButton = useTemplateRef('downloadButton')
|
||||
|
||||
/** @type {import('vue').WatchHandle | undefined} */
|
||||
let downloadDropdownWatcher
|
||||
|
||||
onMounted(() => {
|
||||
if (process.env.IS_ELECTRON || 'mediaSession' in navigator) {
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: props.title,
|
||||
artist: props.channelName,
|
||||
artwork: [{
|
||||
src: props.videoThumbnail,
|
||||
sizes: '128x128',
|
||||
type: 'img/png'
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
// live and post-live DVR don't have a download button
|
||||
if (downloadButton.value) {
|
||||
downloadDropdownWatcher = watch(() => downloadButton.value.dropdownShown, (dropdownShown) => {
|
||||
emit('set-info-area-sticky', !dropdownShown)
|
||||
|
||||
if (dropdownShown && window.innerWidth >= 901) {
|
||||
// adds a slight delay so we know that the dropdown has shown up
|
||||
// and won't mess up our scrolling
|
||||
nextTick(() => {
|
||||
emit('scroll-to-info-area')
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (downloadDropdownWatcher) {
|
||||
downloadDropdownWatcher.stop()
|
||||
downloadDropdownWatcher = undefined
|
||||
}
|
||||
})
|
||||
|
||||
/** @type {import('vue').ComputedRef<'download' | 'open'>} */
|
||||
const downloadBehavior = computed(() => store.getters.getDownloadBehavior)
|
||||
|
||||
/**
|
||||
* @param {number} index
|
||||
*/
|
||||
function handleDownload(index) {
|
||||
const selectedDownloadLinkOption = props.downloadLinks[index]
|
||||
const mimeTypeUrl = selectedDownloadLinkOption.value.split('||')
|
||||
|
||||
if (!process.env.IS_ELECTRON || downloadBehavior.value === 'open') {
|
||||
openExternalLink(mimeTypeUrl[1])
|
||||
} else {
|
||||
store.dispatch('downloadMedia', {
|
||||
url: mimeTypeUrl[1],
|
||||
title: props.title,
|
||||
mimeType: mimeTypeUrl[0]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const showPlaylists = computed(() => !store.getters.getHidePlaylists)
|
||||
|
||||
function togglePlaylistPrompt() {
|
||||
const videoData = {
|
||||
videoId: props.id,
|
||||
title: props.title,
|
||||
author: props.channelName,
|
||||
authorId: props.channelId,
|
||||
description: props.description,
|
||||
viewCount: props.viewCount,
|
||||
lengthSeconds: props.lengthSeconds,
|
||||
published: props.published,
|
||||
premiereDate: props.premiereDate
|
||||
}
|
||||
|
||||
store.dispatch('showAddToPlaylistPromptForManyVideos', { videos: [videoData] })
|
||||
}
|
||||
|
||||
const quickBookmarkPlaylist = computed(() => store.getters.getQuickBookmarkPlaylist)
|
||||
|
||||
const isQuickBookmarkEnabled = computed(() => quickBookmarkPlaylist.value != null)
|
||||
|
||||
const isInQuickBookmarkPlaylist = computed(() => {
|
||||
if (!isQuickBookmarkEnabled.value) { return false }
|
||||
|
||||
// Accessing a reactive property has a negligible amount of overhead,
|
||||
// however as we know that some users have playlists that have more than 10k items in them
|
||||
// it adds up quickly. So create a temporary variable outside of the array, so we only have to do it once.
|
||||
// Also the search is retriggered every time any playlist is modified.
|
||||
const id = props.id
|
||||
|
||||
return quickBookmarkPlaylist.value.videos.some((video) => {
|
||||
return video.videoId === id
|
||||
})
|
||||
})
|
||||
|
||||
const quickBookmarkIconText = computed(() => {
|
||||
if (!isQuickBookmarkEnabled.value) { return '' }
|
||||
|
||||
const translationProperties = {
|
||||
playlistName: quickBookmarkPlaylist.value.playlistName,
|
||||
}
|
||||
return isInQuickBookmarkPlaylist.value
|
||||
? t('User Playlists.Remove from Favorites', translationProperties)
|
||||
: t('User Playlists.Add to Favorites', translationProperties)
|
||||
})
|
||||
|
||||
const quickBookmarkIconTheme = computed(() => isInQuickBookmarkPlaylist.value ? 'base favorite' : 'base')
|
||||
|
||||
function toggleQuickBookmarked() {
|
||||
if (!isQuickBookmarkEnabled.value) {
|
||||
// This should be prevented by UI
|
||||
return
|
||||
}
|
||||
|
||||
if (isInQuickBookmarkPlaylist.value) {
|
||||
removeFromQuickBookmarkPlaylist()
|
||||
} else {
|
||||
addToQuickBookmarkPlaylist()
|
||||
}
|
||||
}
|
||||
|
||||
function addToQuickBookmarkPlaylist() {
|
||||
const videoData = {
|
||||
videoId: props.id,
|
||||
title: props.title,
|
||||
author: props.channelName,
|
||||
authorId: props.channelId,
|
||||
lengthSeconds: props.lengthSeconds,
|
||||
published: props.published,
|
||||
premiereDate: props.premiereDate
|
||||
}
|
||||
|
||||
store.dispatch('addVideo', {
|
||||
_id: quickBookmarkPlaylist.value._id,
|
||||
videoData,
|
||||
})
|
||||
|
||||
// TODO: Maybe show playlist name
|
||||
showToast(t('Video.Video has been saved'))
|
||||
}
|
||||
|
||||
function removeFromQuickBookmarkPlaylist() {
|
||||
store.dispatch('removeVideo', {
|
||||
_id: quickBookmarkPlaylist.value._id,
|
||||
// Remove all playlist items with same videoId
|
||||
videoId: props.id,
|
||||
})
|
||||
|
||||
// TODO: Maybe show playlist name
|
||||
showToast(t('Video.Video has been removed from your saved list'))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped src="./WatchVideoInfo.css" />
|
||||
@@ -29,7 +29,6 @@
|
||||
</div>
|
||||
<div
|
||||
v-else-if="comments.length === 0"
|
||||
ref="liveChatMessage"
|
||||
class="messageContainer liveChatMessage"
|
||||
>
|
||||
<p
|
||||
@@ -225,7 +224,7 @@
|
||||
<script setup>
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import autolinker from 'autolinker'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, shallowReactive } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, shallowReactive, useTemplateRef } from 'vue'
|
||||
import { useI18n } from '../../composables/use-i18n-polyfill'
|
||||
import { YTNodes } from 'youtubei.js'
|
||||
|
||||
@@ -370,8 +369,7 @@ function startLiveChatLocal() {
|
||||
liveChatInstance.start()
|
||||
}
|
||||
|
||||
/** @type {import('vue').Ref<HTMLDivElement | null>} */
|
||||
const commentsRef = ref(null)
|
||||
const commentsRef = useTemplateRef('commentsRef')
|
||||
|
||||
/**
|
||||
* @param {import ('youtubei.js/dist/src/parser/continuations').LiveChatContinuation} initialData
|
||||
|
||||
@@ -87,7 +87,7 @@ export default defineComponent({
|
||||
window.addEventListener('resize', this.handleResize)
|
||||
}
|
||||
},
|
||||
beforeDestroy: function () {
|
||||
beforeUnmount: function () {
|
||||
if (this.dropdownModalOnMobile) {
|
||||
window.removeEventListener('resize', this.handleResize)
|
||||
}
|
||||
|
||||
@@ -9,11 +9,13 @@
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
background-color: transparent;
|
||||
border-radius: 50%;
|
||||
border-style: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
block-size: 1em;
|
||||
line-height: 1em;
|
||||
transition: background 0.15s ease-out;
|
||||
inline-size: 1em;
|
||||
|
||||
&.shadow {
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / 50%);
|
||||
@@ -115,6 +117,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
inline-size: 1em;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: auto;
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
class="ftIconButton"
|
||||
@focusout="handleDropdownFocusOut"
|
||||
>
|
||||
<font-awesome-icon
|
||||
<button
|
||||
class="iconButton"
|
||||
:aria-label="title"
|
||||
:title="title"
|
||||
:icon="icon"
|
||||
:class="{
|
||||
[theme]: true,
|
||||
shadow: useShadow,
|
||||
@@ -18,16 +18,17 @@
|
||||
padding: padding + 'px',
|
||||
fontSize: size + 'px'
|
||||
}"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
:aria-disabled="disabled"
|
||||
:aria-expanded="dropdownShown"
|
||||
@pointerdown="handleIconPointerDown"
|
||||
@contextmenu.prevent=""
|
||||
@click="handleIconClick"
|
||||
@keydown.enter.prevent="handleIconClick"
|
||||
@keydown.space.prevent="handleIconClick"
|
||||
/>
|
||||
>
|
||||
<font-awesome-icon
|
||||
class="icon"
|
||||
:icon="icon"
|
||||
/>
|
||||
</button>
|
||||
<template
|
||||
v-if="dropdownShown"
|
||||
>
|
||||
|
||||
@@ -1,391 +0,0 @@
|
||||
import { defineComponent } from 'vue'
|
||||
import { mapActions } from 'vuex'
|
||||
|
||||
import FtTooltip from '../FtTooltip/FtTooltip.vue'
|
||||
import { isKeyboardEventKeyPrintableChar, isNullOrEmpty } from '../../helpers/strings'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FtInput',
|
||||
components: {
|
||||
'ft-tooltip': FtTooltip
|
||||
},
|
||||
props: {
|
||||
inputType: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'text'
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
maxlength: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
showActionButton: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
forceActionButtonIconName: {
|
||||
type: Array,
|
||||
default: null
|
||||
},
|
||||
showClearTextButton: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showLabel: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isSearch: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
dataList: {
|
||||
type: Array,
|
||||
default: () => { return [] }
|
||||
},
|
||||
dataListProperties: {
|
||||
type: Array,
|
||||
default: () => { return [] }
|
||||
},
|
||||
searchResultIconNames: {
|
||||
type: Array,
|
||||
default: null
|
||||
},
|
||||
showDataWhenEmpty: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
tooltip: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
emits: ['clear', 'click', 'input', 'remove'],
|
||||
data: function () {
|
||||
let actionIcon = ['fas', 'search']
|
||||
if (this.forceActionButtonIconName !== null) {
|
||||
actionIcon = this.forceActionButtonIconName
|
||||
}
|
||||
return {
|
||||
id: '',
|
||||
inputData: '',
|
||||
searchState: {
|
||||
showOptions: false,
|
||||
selectedOption: -1,
|
||||
isPointerInList: false,
|
||||
keyboardSelectedOptionIndex: -1,
|
||||
},
|
||||
visibleDataList: this.dataList,
|
||||
// This button should be invisible on app start
|
||||
// As the text input box should be empty
|
||||
clearTextButtonExisting: false,
|
||||
clearTextButtonVisible: false,
|
||||
removeButtonSelectedIndex: -1,
|
||||
removalMade: false,
|
||||
actionButtonIconName: actionIcon
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
showOptions: function () {
|
||||
return (this.inputData !== '' || this.showDataWhenEmpty) && this.visibleDataList.length > 0 && this.searchState.showOptions
|
||||
},
|
||||
|
||||
barColor: function () {
|
||||
return this.$store.getters.getBarColor
|
||||
},
|
||||
|
||||
forceTextColor: function () {
|
||||
return this.isSearch && this.barColor
|
||||
},
|
||||
|
||||
inputDataPresent: function () {
|
||||
return this.inputDataDisplayed.length > 0
|
||||
},
|
||||
|
||||
inputDataDisplayed() {
|
||||
if (!this.isSearch) { return this.inputData }
|
||||
|
||||
const selectedOptionValue = this.searchStateKeyboardSelectedOptionValue
|
||||
if (selectedOptionValue != null && selectedOptionValue !== '') {
|
||||
return selectedOptionValue
|
||||
}
|
||||
|
||||
return this.inputData
|
||||
},
|
||||
|
||||
searchStateKeyboardSelectedOptionValue() {
|
||||
if (this.searchState.keyboardSelectedOptionIndex === -1) { return null }
|
||||
return this.visibleDataList[this.searchState.keyboardSelectedOptionIndex]
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
dataList(val, oldVal) {
|
||||
if (val !== oldVal) {
|
||||
this.updateVisibleDataList()
|
||||
}
|
||||
},
|
||||
inputData(val, oldVal) {
|
||||
if (val !== oldVal) {
|
||||
this.updateVisibleDataList()
|
||||
}
|
||||
},
|
||||
value(val, oldVal) {
|
||||
if (val !== oldVal) {
|
||||
this.inputData = val
|
||||
}
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
this.id = this._uid
|
||||
this.inputData = this.value
|
||||
this.updateVisibleDataList()
|
||||
},
|
||||
methods: {
|
||||
handleClick: function (e) {
|
||||
const selectedValue = this.searchStateKeyboardSelectedOptionValue
|
||||
const query = (selectedValue != null && selectedValue !== '') ? selectedValue : this.inputData
|
||||
this.inputData = query
|
||||
// No action if no input text
|
||||
if (!this.inputDataPresent) {
|
||||
return
|
||||
}
|
||||
|
||||
this.searchState.showOptions = false
|
||||
this.searchState.selectedOption = -1
|
||||
this.searchState.keyboardSelectedOptionIndex = -1
|
||||
this.removeButtonSelectedIndex = -1
|
||||
this.$emit('input', query)
|
||||
this.$emit('click', query, { event: e })
|
||||
},
|
||||
|
||||
handleInput: function (val) {
|
||||
this.inputData = val
|
||||
|
||||
if (this.isSearch &&
|
||||
this.searchState.selectedOption !== -1 &&
|
||||
this.inputData === this.visibleDataList[this.searchState.selectedOption]) { return }
|
||||
this.handleActionIconChange()
|
||||
this.$emit('input', val)
|
||||
},
|
||||
|
||||
handleClearTextClick: function () {
|
||||
// No action if no input text
|
||||
if (!this.inputDataPresent) { return }
|
||||
|
||||
this.inputData = ''
|
||||
this.handleActionIconChange()
|
||||
this.updateVisibleDataList()
|
||||
this.searchState.isPointerInList = false
|
||||
|
||||
this.$refs.input.value = ''
|
||||
|
||||
// Focus on input element after text is clear for better UX
|
||||
this.$refs.input.focus()
|
||||
|
||||
this.$emit('clear')
|
||||
},
|
||||
|
||||
handleActionIconChange: function() {
|
||||
// Only need to update icon if visible
|
||||
if (!this.showActionButton) { return }
|
||||
|
||||
if (!this.inputDataPresent && this.forceActionButtonIconName === null) {
|
||||
// Change back to default icon if text is blank
|
||||
this.actionButtonIconName = ['fas', 'search']
|
||||
return
|
||||
}
|
||||
|
||||
// Update action button icon according to input
|
||||
try {
|
||||
this.getYoutubeUrlInfo(this.inputData).then((result) => {
|
||||
let isYoutubeLink = false
|
||||
|
||||
switch (result.urlType) {
|
||||
case 'video':
|
||||
case 'playlist':
|
||||
case 'search':
|
||||
case 'channel':
|
||||
case 'hashtag':
|
||||
case 'post':
|
||||
case 'trending':
|
||||
case 'subscriptions':
|
||||
case 'history':
|
||||
case 'userplaylists':
|
||||
isYoutubeLink = true
|
||||
break
|
||||
|
||||
case 'invalid_url':
|
||||
default: {
|
||||
// isYoutubeLink is already `false`
|
||||
}
|
||||
}
|
||||
if (this.forceActionButtonIconName === null) {
|
||||
if (isYoutubeLink) {
|
||||
// Go to URL (i.e. Video/Playlist/Channel
|
||||
this.actionButtonIconName = ['fas', 'arrow-right']
|
||||
} else {
|
||||
// Search with text
|
||||
this.actionButtonIconName = ['fas', 'search']
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (ex) {
|
||||
// On exception, consider text as invalid URL
|
||||
if (this.forceActionButtonIconName === null) {
|
||||
this.actionButtonIconName = ['fas', 'search']
|
||||
}
|
||||
// Rethrow exception
|
||||
throw ex
|
||||
}
|
||||
},
|
||||
|
||||
handleOptionClick: function (index) {
|
||||
if (this.removeButtonSelectedIndex !== -1) {
|
||||
this.handleRemoveClick(index)
|
||||
return
|
||||
}
|
||||
this.searchState.showOptions = false
|
||||
this.inputData = this.visibleDataList[index]
|
||||
this.$emit('input', this.inputData)
|
||||
this.handleClick()
|
||||
},
|
||||
|
||||
handleRemoveClick: function (index) {
|
||||
if (!this.dataListProperties[index]?.isRemoveable) { return }
|
||||
|
||||
// keep input in focus even when the to-be-removed "Remove" button was clicked
|
||||
this.$refs.input.focus()
|
||||
this.removalMade = true
|
||||
this.$emit('remove', this.visibleDataList[index])
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
handleKeyDown: function (event) {
|
||||
// Update Input box value if enter key was pressed and option selected
|
||||
if (event.key === 'Enter' && !event.isComposing) {
|
||||
if (this.removeButtonSelectedIndex !== -1) {
|
||||
this.handleRemoveClick(this.removeButtonSelectedIndex)
|
||||
} else if (this.searchState.selectedOption !== -1) {
|
||||
this.searchState.showOptions = false
|
||||
event.preventDefault()
|
||||
this.inputData = this.visibleDataList[this.searchState.selectedOption]
|
||||
this.handleOptionClick(this.searchState.selectedOption)
|
||||
} else {
|
||||
this.handleClick(event)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (this.visibleDataList.length === 0) { return }
|
||||
|
||||
this.searchState.showOptions = true
|
||||
|
||||
// "select" the Remove button through right arrow navigation, and unselect it with the left arrow
|
||||
if (event.key === 'ArrowRight') {
|
||||
this.removeButtonSelectedIndex = this.searchState.selectedOption
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
this.removeButtonSelectedIndex = -1
|
||||
} else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
const newIndex = this.searchState.selectedOption + (event.key === 'ArrowDown' ? 1 : -1)
|
||||
this.updateSelectedOptionIndex(newIndex)
|
||||
} else {
|
||||
const selectedOptionValue = this.searchStateKeyboardSelectedOptionValue
|
||||
// Keyboard selected & is char
|
||||
if (!isNullOrEmpty(selectedOptionValue) && isKeyboardEventKeyPrintableChar(event.key)) {
|
||||
// Update input based on KB selected suggestion value instead of current input value
|
||||
event.preventDefault()
|
||||
this.handleInput(`${selectedOptionValue}${event.key}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Updates the selected dropdown option index and handles the under/over-flow behavior
|
||||
updateSelectedOptionIndex: function (index) {
|
||||
this.searchState.selectedOption = index
|
||||
|
||||
// unset selection of "Remove" button
|
||||
this.removeButtonSelectedIndex = -1
|
||||
|
||||
// Allow deselecting suggestion
|
||||
if (this.searchState.selectedOption < -1) {
|
||||
this.searchState.selectedOption = this.visibleDataList.length - 1
|
||||
} else if (this.searchState.selectedOption > this.visibleDataList.length - 1) {
|
||||
this.searchState.selectedOption = -1
|
||||
}
|
||||
|
||||
// Update displayed value
|
||||
this.searchState.keyboardSelectedOptionIndex = this.searchState.selectedOption
|
||||
},
|
||||
|
||||
handleInputBlur: function () {
|
||||
if (!this.searchState.isPointerInList) { this.searchState.showOptions = false }
|
||||
},
|
||||
|
||||
handleFocus: function () {
|
||||
this.searchState.showOptions = true
|
||||
},
|
||||
|
||||
updateVisibleDataList: function () {
|
||||
// Reset selected option before it's updated
|
||||
// Block resetting if it was just the "Remove" button that was pressed
|
||||
if (!this.removalMade || this.searchState.selectedOption >= this.dataList.length) {
|
||||
this.searchState.selectedOption = -1
|
||||
this.searchState.keyboardSelectedOptionIndex = -1
|
||||
this.removeButtonSelectedIndex = -1
|
||||
}
|
||||
|
||||
this.removalMade = false
|
||||
|
||||
if (this.inputData.trim() === '') {
|
||||
this.visibleDataList = this.dataList
|
||||
return
|
||||
}
|
||||
// get list of items that match input
|
||||
const lowerCaseInputData = this.inputData.toLowerCase()
|
||||
|
||||
this.visibleDataList = this.dataList.filter(x => {
|
||||
return x.toLowerCase().indexOf(lowerCaseInputData) !== -1
|
||||
})
|
||||
},
|
||||
|
||||
updateInputData: function(text) {
|
||||
this.inputData = text
|
||||
},
|
||||
|
||||
focus() {
|
||||
this.$refs.input.focus()
|
||||
},
|
||||
|
||||
select() {
|
||||
this.$refs.input.select()
|
||||
},
|
||||
|
||||
blur() {
|
||||
this.$refs.input.blur()
|
||||
},
|
||||
|
||||
...mapActions([
|
||||
'getYoutubeUrlInfo'
|
||||
])
|
||||
}
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user