diff --git a/.github/issue-labeler.yml b/.github/issue-labeler.yml index 2bd615782823c..1d08f2788ff0b 100644 --- a/.github/issue-labeler.yml +++ b/.github/issue-labeler.yml @@ -2,10 +2,10 @@ - '(visual bug)' 'B: Unofficial Download': - - '(AUR \(Unofficial\)|Chocolatey \(Unofficial\)|\.apk \(Android, FreeTubeCordova Unofficial\)|PortableApps \(Unofficial\)|winget \(Unofficial\)|Scoop \(Unofficial\)|Snapcraft \(Unofficial\)|MPR \(Unofficial\)|Nix \(Unofficial\))' + - '(AUR \(Unofficial\)|Chocolatey \(Unofficial\)|\.apk \(Android, FreeTubeCordova Unofficial\)|Homebrew \(Unofficial\)|PortableApps \(Unofficial\)|WAPT \(Unofficial\)|winget \(Unofficial\)|Scoop \(Unofficial\)|Snapcraft \(Unofficial\)|MPR \(Unofficial\)|Nix \(Unofficial\))' 'B: keyboard control': - - '(keyboard control not working)' + - '(keyboard control not working)' 'B: text/string': - '(text/string issue)' diff --git a/.github/pr-labeler.yml b/.github/pr-labeler.yml index dff087046453f..a6f19b9ca8c3c 100644 --- a/.github/pr-labeler.yml +++ b/.github/pr-labeler.yml @@ -1,19 +1,8 @@ 'PR: waiting for review': - - '*' - - '.babelrc' - - '.editorconfig' - - '.eslintignore' - - '.eslintrc.js' - - '.gitignore' - - '.prettierrc' - - '.whitesource' - - '.github/**/*' - - '.vscode/**/*' - - '_icons/**/*' - - '_scripts/**/*' - - 'src/**/*' - - 'static/**/*' +- changed-files: + - any-glob-to-any-file: '**' 'PR: dependencies': - - 'yarn.lock' - - 'package.json' +- any: + - changed-files: + - any-glob-to-any-file: ['yarn.lock', 'package.json'] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2577d16349f73..569ded4476692 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -50,7 +50,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: "yarn" @@ -64,7 +64,7 @@ jobs: - name: Set Version Number Variable id: versionNumber - uses: actions/github-script@v6 + uses: actions/github-script@v7 env: IS_DEV: ${{ contains(github.ref, 'development') }} IS_RC: ${{ contains(github.ref, 'RC') }} @@ -108,91 +108,91 @@ jobs: run: yarn run build:arm64 - name: Upload Linux .zip x64 Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64') with: name: freetube_${{ steps.versionNumber.outputs.result }}_linux_portable_x64 path: build/freetube-${{ steps.versionNumber.outputs.result }}.zip - name: Upload Linux .7z x64 Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64') with: name: freetube_${{ steps.versionNumber.outputs.result }}_linux_portable_x64.7z path: build/freetube-${{ steps.versionNumber.outputs.result }}.7z - name: Upload Linux .zip ARMv7l Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l') with: name: freetube_${{ steps.versionNumber.outputs.result }}_linux_portable_armv7l path: build/freetube-${{ steps.versionNumber.outputs.result }}-armv7l.zip - name: Upload Linux .7z ARMv7l Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l') with: name: freetube_${{ steps.versionNumber.outputs.result }}_linux_portable_armv7l.7z path: build/freetube-${{ steps.versionNumber.outputs.result }}-armv7l.7z - name: Upload Linux .zip ARM64 Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64') with: name: freetube_${{ steps.versionNumber.outputs.result }}_linux_portable_arm64 path: build/freetube-${{ steps.versionNumber.outputs.result }}-arm64.zip - name: Upload Linux .7z ARM64 Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64') with: name: freetube_${{ steps.versionNumber.outputs.result }}_linux_portable_arm64.7z path: build/freetube-${{ steps.versionNumber.outputs.result }}-arm64.7z - name: Upload .deb x64 Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 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@v3 + uses: actions/upload-artifact@v4 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@v3 + uses: actions/upload-artifact@v4 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@v3 + uses: actions/upload-artifact@v4 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@v3 + uses: actions/upload-artifact@v4 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@v3 + uses: actions/upload-artifact@v4 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@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64') with: name: freetube_${{ steps.versionNumber.outputs.result }}_amd64.rpm @@ -201,133 +201,133 @@ jobs: # rpm are not built for armv7l - name: Upload .rpm ARM64 Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 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 Alpine .apk x64 Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-x64') with: name: freetube_${{ steps.versionNumber.outputs.result }}_alpine_amd64.apk path: build/freetube-${{ steps.versionNumber.outputs.result }}.apk - name: Upload Alpine .apk ARMv7l Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-armv7l') with: name: freetube_${{ steps.versionNumber.outputs.result }}_alpine_armv7l.apk path: build/freetube-${{ steps.versionNumber.outputs.result }}-armv7l.apk - name: Upload Alpine .apk ARM64 Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.runtime, 'linux-arm64') with: name: freetube_${{ steps.versionNumber.outputs.result }}_alpine_arm64.apk path: build/freetube-${{ steps.versionNumber.outputs.result }}-arm64.apk - name: Upload Pacman .pacman x64 Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 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@v3 + # uses: actions/upload-artifact@v4 # 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@v3 + uses: actions/upload-artifact@v4 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 arm64 .exe Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 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 x64 .zip Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-x64') with: name: freetube-${{ steps.versionNumber.outputs.result }}-win-x64-portable path: build/freetube-${{ steps.versionNumber.outputs.result }}-win.zip - name: Upload Windows x64 .7z Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 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 .zip Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-arm64') with: name: freetube-${{ steps.versionNumber.outputs.result }}-win-arm64-portable path: build/freetube-${{ steps.versionNumber.outputs.result }}-arm64-win.zip - name: Upload Windows arm64 .7z Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 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 Windows x64 Portable Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-x64') with: name: freetube-${{ steps.versionNumber.outputs.result }}-portable-x64.exe path: build/freetube ${{ steps.versionNumber.outputs.result }}.exe - name: Upload Windows arm64 Portable Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: startsWith(matrix.os, 'windows') && startsWith(matrix.runtime, 'win-arm64') with: name: freetube-${{ steps.versionNumber.outputs.result }}-portable-arm64.exe path: build/freetube ${{ steps.versionNumber.outputs.result }}.exe - name: Upload Mac x64 .dmg Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 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 arm64 .dmg Artifact -# uses: actions/upload-artifact@v3 +# uses: actions/upload-artifact@v4 # 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 x64 .zip Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 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@v3 + uses: actions/upload-artifact@v4 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 .zip Artifact -# uses: actions/upload-artifact@v3 +# uses: actions/upload-artifact@v4 # if: startsWith(matrix.os, 'macos') && startsWith(matrix.runtime, 'osx-arm64') # with: # name: freetube-${{ steps.versionNumber.outputs.result }}-mac-arm64.zip diff --git a/.github/workflows/buildCordova.yml b/.github/workflows/buildCordova.yml index f449cc3a43101..22f03170e5e7e 100644 --- a/.github/workflows/buildCordova.yml +++ b/.github/workflows/buildCordova.yml @@ -89,16 +89,8 @@ jobs: name: freetube-${{ steps.versionNumber.outputs.result }}-PWA path: dist/web - - name: 🚧 Setup Android SDK Tools - uses: android-actions/setup-android@v2.0.9 - - - name: ⬇ Download dependency `cordova-plugin-run-in-background` - run: | - git clone https://bitbucket.org/TheBosZ/cordova-plugin-run-in-background.git - sed -i 's@git+https://bitbucket.org/TheBosZ/cordova-plugin-run-in-background.git@../../cordova-plugin-run-in-background@g' ./src/cordova/package.js - - - name: 📦 Pack for 📱Android with Node.js & Cordova - run: yarn pack:cordova + - name: 📦 Pack for 📱Android + run: yarn pack:android:dev - name: 🦴 Fetch keystore from secrets run: | @@ -108,10 +100,37 @@ jobs: done <<< '${{ secrets.KEYSTORE }}' gpg -d --passphrase '${{ secrets.KEYSTORE_PASSWORD }}' --batch freetube.keystore.asc >> freetube.keystore - - name: 👷‍♀️ Build APK with Cordova with Node.js - run: yarn build:cordova freetube-${{ steps.versionNumber.outputs.result }}.apk ./freetube.keystore ${{ secrets.KEYSTORE_PASSWORD }} + - name: Inject signing config from secrets + run: | + sed -i 's@// inject signing config@// inject signing config\r\nstorePassword = "${{ secrets.KEYSTORE_PASSWORD }}"@' android/app/build.gradle.kts + sed -i 's@// inject signing config@// inject signing config\r\nstoreFile = file("../../freetube.keystore")@' android/app/build.gradle.kts + sed -i 's@// inject signing config@// inject signing config\r\nkeyPassword = "${{ secrets.KEYSTORE_PASSWORD }}"@' android/app/build.gradle.kts + sed -i 's@// inject signing config@keyAlias = "freetubecordova"@' android/app/build.gradle.kts + cat android/app/build.gradle.kts + + - name: Update name + run: | + sed -i 's/"FreeTube Android"/"FreeTube Nightly"/g' android/settings.gradle.kts + sed -i 's/FreeTube Android/FreeTube Nightly/g' android/app/src/main/res/values/strings.xml + + - name: set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: cd android/ && chmod +x gradlew + + - name: Build with Gradle + run: cd android/ && ./gradlew build + + - name: Rename APK w/ version info + run: | + cp android/app/build/outputs/apk/debug/app-debug.apk ./dist/freetube-${{ steps.versionNumber.outputs.result }}-Android.apk - - name: 📡 Upload Cordova APK Artifact + - name: 📡 Upload APK Artifact uses: actions/upload-artifact@v3 with: name: freetube-${{ steps.versionNumber.outputs.result }}-Android.apk diff --git a/.github/workflows/calibreapp-image-actions.yml b/.github/workflows/calibreapp-image-actions.yml index f94e1ac32c843..d336cad2328ab 100644 --- a/.github/workflows/calibreapp-image-actions.yml +++ b/.github/workflows/calibreapp-image-actions.yml @@ -20,7 +20,7 @@ jobs: compressOnly: true - name: Create New Pull Request If Needed if: steps.calibre.outputs.markdown != '' - uses: peter-evans/create-pull-request@v5 + uses: peter-evans/create-pull-request@v6 with: title: Compressed Images Nightly branch-suffix: timestamp diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 500b94b7d3239..521082d630464 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -31,7 +31,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -45,7 +45,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@v2 + uses: github/codeql-action/autobuild@v3 # ℹ️ 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 @@ -58,6 +58,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml index 83ad8fe80862f..f94292cdbcc6f 100644 --- a/.github/workflows/flatpak.yml +++ b/.github/workflows/flatpak.yml @@ -103,7 +103,7 @@ jobs: rm freetube-${{ steps.sub.outputs.result }}-linux-portable-x64.zip rm freetube-${{ steps.sub.outputs.result }}-linux-portable-arm64.zip - name: Commit Files - uses: stefanzweifel/git-auto-commit-action@v4 + uses: stefanzweifel/git-auto-commit-action@v5 with: # Optional but recommended # Defaults to "Apply automatic changes" diff --git a/.github/workflows/label-issue.yml b/.github/workflows/label-issue.yml index 8eb773f509979..24e42dc04f3c9 100644 --- a/.github/workflows/label-issue.yml +++ b/.github/workflows/label-issue.yml @@ -11,7 +11,7 @@ jobs: triage: runs-on: ubuntu-latest steps: - - uses: github/issue-labeler@v3.2 + - uses: github/issue-labeler@v3.4 with: configuration-path: .github/issue-labeler.yml enable-versioned-regex: 0 diff --git a/.github/workflows/label-pr.yml b/.github/workflows/label-pr.yml index 9df23f2053b03..5c9c9927693d3 100644 --- a/.github/workflows/label-pr.yml +++ b/.github/workflows/label-pr.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest if: ${{ !github.event.pull_request.draft }} steps: - - uses: actions/labeler@v4 + - uses: actions/labeler@v5 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" configuration-path: .github/pr-labeler.yml diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index a2d6f8dad5294..218d7c5640d06 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -19,7 +19,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Use Node.js 18.x - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 18.x cache: "yarn" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5312a30926627..519492e79bbe4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,7 +50,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: "yarn" diff --git a/.github/workflows/report.yml b/.github/workflows/report.yml index cbe16d3bd5ed4..834da028dd957 100644 --- a/.github/workflows/report.yml +++ b/.github/workflows/report.yml @@ -13,7 +13,7 @@ jobs: # For bug reports - name: New bug issue - uses: alex-page/github-project-automation-plus@v0.8.3 + uses: alex-page/github-project-automation-plus@v0.9.0 if: contains(github.event.issue.labels.*.name, 'bug') && github.event.action == 'opened' with: project: Bug Reports @@ -22,7 +22,7 @@ jobs: action: update - name: Bug issue closed - uses: alex-page/github-project-automation-plus@v0.8.3 + uses: alex-page/github-project-automation-plus@v0.9.0 if: github.event.action == 'closed' || github.event.action == 'deleted' with: action: delete @@ -31,7 +31,7 @@ jobs: repo-token: ${{ secrets.PUSH_TOKEN }} - name: Bug issue reopened - uses: alex-page/github-project-automation-plus@v0.8.3 + uses: alex-page/github-project-automation-plus@v0.9.0 if: contains(github.event.issue.labels.*.name, 'bug') && github.event.action == 'reopened' with: project: Bug Reports @@ -41,7 +41,7 @@ jobs: # For feature requests - name: New feature issue - uses: alex-page/github-project-automation-plus@v0.8.3 + uses: alex-page/github-project-automation-plus@v0.9.0 if: contains(github.event.issue.labels.*.name, 'enhancement') && github.event.action == 'opened' with: project: Feature Requests @@ -50,7 +50,7 @@ jobs: action: update - name: Feature request issue closed - uses: alex-page/github-project-automation-plus@v0.8.3 + uses: alex-page/github-project-automation-plus@v0.9.0 if: github.event.action == 'closed' || github.event.action == 'deleted' with: action: delete @@ -59,7 +59,7 @@ jobs: repo-token: ${{ secrets.PUSH_TOKEN }} - name: Feature request issue reopened - uses: alex-page/github-project-automation-plus@v0.8.3 + uses: alex-page/github-project-automation-plus@v0.9.0 if: contains(github.event.issue.labels.*.name, 'enhancement') && github.event.action == 'reopened' with: project: Feature Requests diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 5fd8db421addb..6945419318d9a 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -11,7 +11,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v8 + - uses: actions/stale@v9 with: stale-issue-message: 'This issue is stale because it has been open 28 days with no activity. Remove stale label or comment or this will be closed in 7 days.' stale-pr-message: 'This PR is stale because it has been open 28 days with no activity. Remove stale label or comment or this will be closed in 14 days.' diff --git a/.gitignore b/.gitignore index 2dbcd0748b75a..663025167b975 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ __coverage__ csak-timelog.json .idea/ debug/ - +assets/ # Lefthook lefthook-local.yml +*.bak diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10f6618953db1..32a86cb197dd2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,7 +20,7 @@ Please follow these guidelines before sending your pull request and making contr * 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 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 two 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. +* 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. # Setting up Your Environment diff --git a/README.md b/README.md index 0e0cdae623c2a..dc1fb0a679539 100644 --- a/README.md +++ b/README.md @@ -79,14 +79,10 @@ The first build with a green check mark is the latest build. You will need to ha ## How to build and test ### Commands for the Android APK ```bash -# 📦 Packs the project using `webpack.cordova.config.js` -yarn pack:cordova -# 🏗 Builds the debug APK and launches it on a connected device -yarn run:cordova -# 🚧 Builds the development APK -yarn build:cordova -# 🏦 Builds the release APK -yarn build:cordova --release +# 📦 Packs the project using `webpack.android.config.js` +yarn pack:android +# 🚧 for development +yarn pack:android:dev ``` ### Commands for the PWA (progressive web app) ```bash diff --git a/_icons/iconNordicLightSmall.png b/_icons/iconNordicLightSmall.png new file mode 100644 index 0000000000000..b5b83494c8e56 Binary files /dev/null and b/_icons/iconNordicLightSmall.png differ diff --git a/_icons/textNordicLightSmall.png b/_icons/textNordicLightSmall.png new file mode 100644 index 0000000000000..b23422a08c14f Binary files /dev/null and b/_icons/textNordicLightSmall.png differ diff --git a/_scripts/ProcessLocalesPlugin.js b/_scripts/ProcessLocalesPlugin.js index 0d2dd681849a2..e79345e106566 100644 --- a/_scripts/ProcessLocalesPlugin.js +++ b/_scripts/ProcessLocalesPlugin.js @@ -1,4 +1,4 @@ -const { existsSync, readFileSync } = require('fs') +const { existsSync, readFileSync, statSync } = require('fs') const { brotliCompress, constants } = require('zlib') const { promisify } = require('util') const { load: loadYaml } = require('js-yaml') @@ -8,6 +8,7 @@ const brotliCompressAsync = promisify(brotliCompress) class ProcessLocalesPlugin { constructor(options = {}) { this.compress = !!options.compress + this.isIncrementalBuild = false if (typeof options.inputDir !== 'string') { throw new Error('ProcessLocalesPlugin: no input directory `inputDir` specified.') @@ -21,10 +22,11 @@ class ProcessLocalesPlugin { } this.outputDir = options.outputDir - this.locales = [] + this.locales = {} this.localeNames = [] + this.activeLocales = [] - this.cache = [] + this.cache = {} this.loadLocales() } @@ -37,66 +39,94 @@ class ProcessLocalesPlugin { compilation.hooks.additionalAssets.tapPromise('process-locales-plugin', async (_assets) => { - // While running in the webpack dev server, this hook gets called for every incrememental build. + // While running in the webpack dev server, this hook gets called for every incremental build. // For incremental builds we can return the already processed versions, which saves time // and makes webpack treat them as cached - if (IS_DEV_SERVER && this.cache.length > 0) { - for (const { filename, source } of this.cache) { - compilation.emitAsset(filename, source, { minimized: true }) - } + const promises = [] + // Prevents `loadLocales` called twice on first time (e.g. release build) + if (this.isIncrementalBuild) { + this.loadLocales(true) } else { - const promises = [] + this.isIncrementalBuild = true + } - for (const { locale, data } of this.locales) { - promises.push(new Promise(async (resolve) => { - if (Object.prototype.hasOwnProperty.call(data, 'Locale Name')) { - delete data['Locale Name'] - } + Object.values(this.locales).forEach((localeEntry) => { + const { locale, data, mtimeMs } = localeEntry - this.removeEmptyValues(data) + promises.push(new Promise(async (resolve) => { + if (IS_DEV_SERVER) { + const cacheEntry = this.cache[locale] - let filename = `${this.outputDir}/${locale}.json` - let output = JSON.stringify(data) + if (cacheEntry != null) { + const { filename, source, mtimeMs: cachedMtimeMs } = cacheEntry - if (this.compress) { - filename += '.br' - output = await this.compressLocale(output) + if (cachedMtimeMs === mtimeMs) { + compilation.emitAsset(filename, source, { minimized: true }) + resolve() + return + } } + } - let source = new RawSource(output) + this.removeEmptyValues(data) - if (IS_DEV_SERVER) { - source = new CachedSource(source) - this.cache.push({ filename, source }) - } + let filename = `${this.outputDir}/${locale}.json` + let output = JSON.stringify(data) - compilation.emitAsset(filename, source, { minimized: true }) + if (this.compress) { + filename += '.br' + output = await this.compressLocale(output) + } - resolve() - })) - } + let source = new RawSource(output) - await Promise.all(promises) + if (IS_DEV_SERVER) { + source = new CachedSource(source) + this.cache[locale] = { filename, source, mtimeMs } + } + + compilation.emitAsset(filename, source, { minimized: true }) + + resolve() + })) if (IS_DEV_SERVER) { // we don't need the unmodified sources anymore, as we use the cache `this.cache` // so we can clear this to free some memory - delete this.locales + delete localeEntry.data } - } + }) + + await Promise.all(promises) }) }) } - loadLocales() { - const activeLocales = JSON.parse(readFileSync(`${this.inputDir}/activeLocales.json`)) + loadLocales(loadModifiedFilesOnly = false) { + if (this.activeLocales.length === 0) { + this.activeLocales = JSON.parse(readFileSync(`${this.inputDir}/activeLocales.json`)) + } - for (const locale of activeLocales) { - const contents = readFileSync(`${this.inputDir}/${locale}.yaml`, 'utf-8') + for (const locale of this.activeLocales) { + const filePath = `${this.inputDir}/${locale}.yaml` + // Cannot use `mtime` since values never equal + const mtimeMsFromStats = statSync(filePath).mtimeMs + if (loadModifiedFilesOnly) { + // Skip reading files where mtime (modified time) same as last read + // (stored in mtime) + const existingMtime = this.locales[locale]?.mtimeMs + if (existingMtime != null && existingMtime === mtimeMsFromStats) { + continue + } + } + const contents = readFileSync(filePath, 'utf-8') const data = loadYaml(contents) + this.locales[locale] = { locale, data, mtimeMs: mtimeMsFromStats } - this.localeNames.push(data['Locale Name'] ?? locale) - this.locales.push({ locale, data }) + const localeName = data['Locale Name'] ?? locale + if (!loadModifiedFilesOnly) { + this.localeNames.push(localeName) + } } } diff --git a/_scripts/_localforage.js b/_scripts/_localforage.js new file mode 100644 index 0000000000000..f0ee6d8239ec8 --- /dev/null +++ b/_scripts/_localforage.js @@ -0,0 +1,16 @@ +import localforage from '../node_modules/localforage/dist/localforage' +import android from 'android' + +export function createInstance(kwargs) { + const instance = localforage.createInstance(kwargs) + return { + async getItem(key) { + const data = android.readFile("data://", key) + if (data === '') return instance.getItem(key) + return data + }, + async setItem(key, value) { + android.writeFile("data://", key, value) + } + } +} diff --git a/_scripts/add-iv-supported-links.js b/_scripts/add-iv-supported-links.js new file mode 100644 index 0000000000000..d3c0a9d7b4a61 --- /dev/null +++ b/_scripts/add-iv-supported-links.js @@ -0,0 +1,14 @@ + +const { readFile, writeFile } = require('fs/promises') +const { join } = require('path') + +;(async () => { + const manifest = (await readFile(join(__dirname, '../android/app/src/main/AndroidManifest.xml'))).toString() + const invidiousInstances = JSON.parse((await readFile(join(__dirname, '../static/invidious-instances.json'))).toString()) + const supportedLinks = manifest.match(/[\s\S]*?/gm) + const instancesXml = invidiousInstances.map(({ url, cors}) => { + return `` + }).join('\n ') + const postManifest = manifest.replace(supportedLinks[0], `\n ${instancesXml}\n `) + await writeFile(join(__dirname, '../android/app/src/main/AndroidManifest.xml'), postManifest) +})() diff --git a/_scripts/build.js b/_scripts/build.js index 035f986c97dbe..bee33667fbeae 100644 --- a/_scripts/build.js +++ b/_scripts/build.js @@ -1,9 +1,9 @@ const os = require('os') const builder = require('electron-builder') +const config = require('./ebuilder.config.js') const Platform = builder.Platform const Arch = builder.Arch -const { name, productName } = require('../package.json') const args = process.argv let targets @@ -39,97 +39,6 @@ if (platform === 'darwin') { targets = Platform.LINUX.createTarget(['deb', 'zip', '7z', 'apk', 'rpm', 'AppImage', 'pacman'], arch) } -const config = { - appId: `io.freetubeapp.${name}`, - copyright: 'Copyleft © 2020-2023 freetubeapp@protonmail.com', - // asar: false, - // compression: 'store', - productName, - directories: { - output: './build/', - }, - protocols: [ - { - name: "FreeTube", - schemes: [ - "freetube" - ] - } - ], - files: [ - '_icons/iconColor.*', - 'icon.svg', - './dist/**/*', - '!dist/web/*', - '!node_modules/**/*', - ], - dmg: { - contents: [ - { - path: '/Applications', - type: 'link', - x: 410, - y: 230, - }, - { - type: 'file', - x: 130, - y: 230, - }, - ], - window: { - height: 380, - width: 540, - } - }, - linux: { - category: 'Network', - icon: '_icons/icon.svg', - target: ['deb', 'zip', '7z', 'apk', 'rpm', 'AppImage', 'pacman'], - }, - // See the following issues for more information - // https://github.com/jordansissel/fpm/issues/1503 - // https://github.com/jgraph/drawio-desktop/issues/259 - rpm: { - fpm: [`--rpm-rpmbuild-define=_build_id_links none`] - }, - deb: { - depends: [ - "libgtk-3-0", - "libnotify4", - "libnss3", - "libxss1", - "libxtst6", - "xdg-utils", - "libatspi2.0-0", - "libuuid1", - "libsecret-1-0" - ] - }, - mac: { - category: 'public.app-category.utilities', - icon: '_icons/iconMac.icns', - target: ['dmg', 'zip', '7z'], - type: 'distribution', - extendInfo: { - CFBundleURLTypes: [ - 'freetube' - ], - CFBundleURLSchemes: [ - 'freetube' - ] - } - }, - win: { - icon: '_icons/icon.ico', - target: ['nsis', 'zip', '7z', 'portable'], - }, - nsis: { - allowToChangeInstallationDirectory: true, - oneClick: false, - }, -} - builder .build({ targets, diff --git a/_scripts/dev-runner.js b/_scripts/dev-runner.js index d030c8b814035..0a4678040e19f 100644 --- a/_scripts/dev-runner.js +++ b/_scripts/dev-runner.js @@ -1,6 +1,5 @@ process.env.NODE_ENV = 'development' -const open = require('open') const electron = require('electron') const webpack = require('webpack') const WebpackDevServer = require('webpack-dev-server') @@ -161,7 +160,8 @@ function startWeb (callback) { if (!web) { startRenderer(startMain) } else { - startWeb(({ port }) => { + startWeb(async ({ port }) => { + const open = (await import('open')).default open(`http://localhost:${port}`) }) } diff --git a/_scripts/ebuilder.config.js b/_scripts/ebuilder.config.js new file mode 100644 index 0000000000000..5b79d96185685 --- /dev/null +++ b/_scripts/ebuilder.config.js @@ -0,0 +1,94 @@ +const { name, productName } = require('../package.json') + +const config = { + appId: `io.freetubeapp.${name}`, + copyright: 'Copyleft © 2020-2024 freetubeapp@protonmail.com', + // asar: false, + // compression: 'store', + productName, + directories: { + output: './build/', + }, + protocols: [ + { + name: "FreeTube", + schemes: [ + "freetube" + ] + } + ], + files: [ + '_icons/iconColor.*', + 'icon.svg', + './dist/**/*', + '!dist/web/*', + '!node_modules/**/*', + ], + dmg: { + contents: [ + { + path: '/Applications', + type: 'link', + x: 410, + y: 230, + }, + { + type: 'file', + x: 130, + y: 230, + }, + ], + window: { + height: 380, + width: 540, + } + }, + linux: { + category: 'Network', + icon: '_icons/icon.svg', + target: ['deb', 'zip', '7z', 'apk', 'rpm', 'AppImage', 'pacman'], + }, + // See the following issues for more information + // https://github.com/jordansissel/fpm/issues/1503 + // https://github.com/jgraph/drawio-desktop/issues/259 + rpm: { + fpm: [`--rpm-rpmbuild-define=_build_id_links none`] + }, + deb: { + depends: [ + "libgtk-3-0", + "libnotify4", + "libnss3", + "libxss1", + "libxtst6", + "xdg-utils", + "libatspi2.0-0", + "libuuid1", + "libsecret-1-0" + ] + }, + mac: { + category: 'public.app-category.utilities', + icon: '_icons/iconMac.icns', + target: ['dmg', 'zip', '7z'], + type: 'distribution', + extendInfo: { + CFBundleURLTypes: [ + 'freetube' + ], + CFBundleURLSchemes: [ + 'freetube' + ] + } + }, + win: { + icon: '_icons/icon.ico', + target: ['nsis', 'zip', '7z', 'portable'], + }, + nsis: { + allowToChangeInstallationDirectory: true, + oneClick: false, + }, +} + +module.exports = config diff --git a/_scripts/webpack.cordova.config.js b/_scripts/webpack.android.config.js similarity index 71% rename from _scripts/webpack.cordova.config.js rename to _scripts/webpack.android.config.js index 234aecfea3042..0c40c2fd205b8 100644 --- a/_scripts/webpack.cordova.config.js +++ b/_scripts/webpack.android.config.js @@ -8,29 +8,26 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin') const JsonMinimizerPlugin = require('json-minimizer-webpack-plugin') const CssMinimizerPlugin = require('css-minimizer-webpack-plugin') const ProcessLocalesPlugin = require('./ProcessLocalesPlugin') -const CordovaPlugin = require('./CordovaPlugin') const isDevMode = process.env.NODE_ENV === 'development' +const { version: swiperVersion } = JSON.parse(fs.readFileSync(path.join(__dirname, '../node_modules/swiper/package.json'))) + const config = { - name: 'cordova', + name: 'web', mode: process.env.NODE_ENV, devtool: isDevMode ? 'eval-cheap-module-source-map' : false, entry: { web: path.join(__dirname, '../src/renderer/main.js'), }, output: { - path: path.join(__dirname, '../dist/cordova/www'), + path: path.join(__dirname, '../android/app/src/main/assets'), filename: '[name].js', }, - externals: [ - { - electron: '{}', - cordova: 'cordova', - 'music-controls': 'MusicControls', - 'universal-links': 'universalLinks' - } - ], + externals: { + electron: '{}', + android: 'Android' + }, module: { rules: [ { @@ -81,6 +78,10 @@ const config = { } ], }, + { + test: /\.html$/, + use: 'vue-html-loader', + }, { test: /\.(png|jpe?g|gif|tif?f|bmp|webp|svg)(\?.*)?$/, type: 'asset/resource', @@ -115,8 +116,8 @@ const config = { new webpack.DefinePlugin({ 'process.env.IS_ELECTRON': false, 'process.env.IS_ELECTRON_MAIN': false, - 'process.env.IS_CORDOVA': true, - + 'process.env.IS_ANDROID': true, + 'process.env.SWIPER_VERSION': `'${swiperVersion}'`, // video.js' vhs-utils supports both atob() in web browsers and Buffer in node // As the FreeTube web build only runs in web browsers, we can override their check for atob() here: https://github.com/videojs/vhs-utils/blob/main/src/decode-b64-to-uint8-array.js#L3 // overriding that check means we don't need to include a Buffer polyfill @@ -130,32 +131,41 @@ const config = { 'window.atob': true }), new webpack.ProvidePlugin({ - process: 'process/browser', - Buffer: ['buffer', 'Buffer'] + process: 'process/browser' }), new HtmlWebpackPlugin({ excludeChunks: ['processTaskWorker'], filename: 'index.html', template: path.resolve(__dirname, '../src/index.ejs'), nodeModules: false, - inject: false }), new VueLoaderPlugin(), new MiniCssExtractPlugin({ - filename: '[name].css', - chunkFilename: '[id].css', + filename: isDevMode ? '[name].css' : '[name].[contenthash].css', + chunkFilename: isDevMode ? '[id].css' : '[id].[contenthash].css', + }), + new CopyWebpackPlugin({ + patterns: [ + { + from: path.join(__dirname, '../node_modules/swiper/modules/{a11y,navigation,pagination}-element.css').replaceAll('\\', '/'), + to: 'swiper.css', + context: path.join(__dirname, '../node_modules/swiper/modules'), + transformAll: (assets) => { + return Buffer.concat(assets.map(asset => asset.data)) + } + } + ] }) ], resolve: { alias: { - vue$: 'vue/dist/vue.esm.js', - 'jintr': 'jintr-patch', - 'youtubei.js$': 'youtubei.js/web', + vue$: 'vue/dist/vue.runtime.esm.js', + // video.js's mpd-parser uses @xmldom/xmldom so that it can support both node and web browsers // As FreeTube only runs in electron and web browsers, we can use the native DOMParser class, instead of the "polyfill" // https://caniuse.com/mdn-api_domparser '@xmldom/xmldom$': path.resolve(__dirname, '_domParser.js'), - 'localforage': path.resolve(__dirname, "../src/cordova/localforage.js") + 'localforage': path.resolve(__dirname, '_localforage.js') }, fallback: { 'fs/promises': path.resolve(__dirname, '_empty.js'), @@ -171,34 +181,35 @@ const processLocalesPlugin = new ProcessLocalesPlugin({ inputDir: path.join(__dirname, '../static/locales'), outputDir: 'static/locales', }) - +const processAndroidLocales = new ProcessLocalesPlugin({ + compress: false, + inputDir: path.join(__dirname, '../static/locales-android'), + outputDir: 'static/locales-android', +}) config.plugins.push( - new CordovaPlugin(), processLocalesPlugin, + processAndroidLocales, new webpack.DefinePlugin({ 'process.env.LOCALE_NAMES': JSON.stringify(processLocalesPlugin.localeNames), - 'process.env.GEOLOCATION_NAMES': JSON.stringify(fs.readdirSync(path.join(__dirname, '..', 'static', 'geolocations'))) + 'process.env.GEOLOCATION_NAMES': JSON.stringify(fs.readdirSync(path.join(__dirname, '..', 'static', 'geolocations')).map(filename => filename.replace('.json', ''))) }), new CopyWebpackPlugin({ - patterns: [ - { - from: path.join(__dirname, '../static/pwabuilder-sw.js'), - to: path.join(__dirname, '../dist/cordova/www/pwabuilder-sw.js'), - }, - { - from: path.join(__dirname, '../_icons/iconColor.ico'), - to: path.join(__dirname, '../dist/cordova/www/favicon.ico'), - }, - { - from: path.join(__dirname, '../static'), - to: path.join(__dirname, '../dist/cordova/www/static'), - globOptions: { - dot: true, - ignore: ['**/.*', '**/locales/**', '**/pwabuilder-sw.js', '**/dashFiles/**', '**/storyboards/**'], + patterns: [ + { + from: path.join(__dirname, '../static/pwabuilder-sw.js'), + to: path.join(__dirname, '../dist/web/pwabuilder-sw.js'), + }, + { + from: path.join(__dirname, '../static'), + to: path.join(__dirname, '../dist/web/static'), + globOptions: { + dot: true, + ignore: ['**/.*', '**/locales/**', '**/locales-android/**', '**/pwabuilder-sw.js', '**/dashFiles/**', '**/storyboards/**'], + }, }, - }, ] }) ) + module.exports = config diff --git a/_scripts/webpack.renderer.config.js b/_scripts/webpack.renderer.config.js index 8326d08037ecd..5cb371e8a2437 100644 --- a/_scripts/webpack.renderer.config.js +++ b/_scripts/webpack.renderer.config.js @@ -1,13 +1,18 @@ 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 MiniCssExtractPlugin = require('mini-css-extract-plugin') const CssMinimizerPlugin = require('css-minimizer-webpack-plugin') const ProcessLocalesPlugin = require('./ProcessLocalesPlugin') +const WatchExternalFilesPlugin = require('webpack-watch-external-files-plugin') +const CopyWebpackPlugin = require('copy-webpack-plugin') const isDevMode = process.env.NODE_ENV === 'development' +const { version: swiperVersion } = JSON.parse(readFileSync(path.join(__dirname, '../node_modules/swiper/package.json'))) + const processLocalesPlugin = new ProcessLocalesPlugin({ compress: !isDevMode, inputDir: path.join(__dirname, '../static/locales'), @@ -32,11 +37,6 @@ const config = { path: path.join(__dirname, '../dist'), filename: '[name].js', }, - externals: { - cordova: 'browserify/lib/_empty.js', - 'music-controls': 'browserify/lib/_empty.js', - 'universal-links': 'browserify/lib/_empty.js' - }, module: { rules: [ { @@ -119,8 +119,9 @@ const config = { new webpack.DefinePlugin({ 'process.env.IS_ELECTRON': true, 'process.env.IS_ELECTRON_MAIN': false, - 'process.env.IS_CORDOVA': false, - 'process.env.LOCALE_NAMES': JSON.stringify(processLocalesPlugin.localeNames) + '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}'` }), new HtmlWebpackPlugin({ excludeChunks: ['processTaskWorker'], @@ -134,6 +135,18 @@ const config = { new MiniCssExtractPlugin({ filename: isDevMode ? '[name].css' : '[name].[contenthash].css', chunkFilename: isDevMode ? '[id].css' : '[id].[contenthash].css', + }), + new CopyWebpackPlugin({ + patterns: [ + { + from: path.join(__dirname, '../node_modules/swiper/modules/{a11y,navigation,pagination}-element.css').replaceAll('\\', '/'), + to: `swiper-${swiperVersion}.css`, + context: path.join(__dirname, '../node_modules/swiper/modules'), + transformAll: (assets) => { + return Buffer.concat(assets.map(asset => asset.data)) + } + } + ] }) ], resolve: { @@ -152,4 +165,16 @@ const config = { target: 'electron-renderer', } +if (isDevMode) { + const activeLocales = JSON.parse(readFileSync(path.join(__dirname, '../static/locales/activeLocales.json'))) + + config.plugins.push( + new WatchExternalFilesPlugin({ + files: [ + `./static/locales/{${activeLocales.join(',')}}.yaml`, + ], + }), + ) +} + module.exports = config diff --git a/_scripts/webpack.web.config.js b/_scripts/webpack.web.config.js index e0ea06cc4b71c..3b1f90d377fad 100644 --- a/_scripts/webpack.web.config.js +++ b/_scripts/webpack.web.config.js @@ -11,6 +11,8 @@ const ProcessLocalesPlugin = require('./ProcessLocalesPlugin') const isDevMode = process.env.NODE_ENV === 'development' +const { version: swiperVersion } = JSON.parse(fs.readFileSync(path.join(__dirname, '../node_modules/swiper/package.json'))) + const config = { name: 'web', mode: process.env.NODE_ENV, @@ -23,12 +25,10 @@ const config = { filename: '[name].js', }, externals: { - electron: '{}', - cordova: '{}', - 'music-controls': '{}', - 'youtubei.js': '{}', - 'universal-links': '{}' - }, + electron: '{}', + android: '{}', + 'youtubei.js': '{}' + }, module: { rules: [ { @@ -116,9 +116,9 @@ const config = { plugins: [ new webpack.DefinePlugin({ 'process.env.IS_ELECTRON': false, - 'process.env.IS_CORDOVA': false, 'process.env.IS_ELECTRON_MAIN': false, - + 'process.env.SWIPER_VERSION': `'${swiperVersion}'`, + 'process.env.IS_ANDROID': false, // video.js' vhs-utils supports both atob() in web browsers and Buffer in node // As the FreeTube web build only runs in web browsers, we can override their check for atob() here: https://github.com/videojs/vhs-utils/blob/main/src/decode-b64-to-uint8-array.js#L3 // overriding that check means we don't need to include a Buffer polyfill @@ -144,6 +144,18 @@ const config = { new MiniCssExtractPlugin({ filename: isDevMode ? '[name].css' : '[name].[contenthash].css', chunkFilename: isDevMode ? '[id].css' : '[id].[contenthash].css', + }), + new CopyWebpackPlugin({ + patterns: [ + { + from: path.join(__dirname, '../node_modules/swiper/modules/{a11y,navigation,pagination}-element.css').replaceAll('\\', '/'), + to: 'swiper.css', + context: path.join(__dirname, '../node_modules/swiper/modules'), + transformAll: (assets) => { + return Buffer.concat(assets.map(asset => asset.data)) + } + } + ] }) ], resolve: { diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000000000..aa724b77071af --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/android/app/.gitignore b/android/app/.gitignore new file mode 100644 index 0000000000000..42afabfd2abeb --- /dev/null +++ b/android/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000000000..97fc547bfc435 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,95 @@ +import groovy.json.JsonSlurper + + +class VersionInfo { + val appId: String + val version: String + val versionCode: Int + constructor(givenId: String, givenVersion: String, givenCode: Int) { + appId = givenId + version = givenVersion + versionCode = givenCode + } +} + +fun getVersionInfo(project: Project): VersionInfo { + val json = JsonSlurper() + val packageJsonPath = project.file("../../package.json") + + val packageJson = json.parse(packageJsonPath) as Map + val versionName = packageJson["version"] as String + val appName = "io.freetubeapp." + packageJson["name"] + val parts = versionName.split("-") + val numbers = parts[0].split(".") + val major = numbers[0].toInt() + val minor = numbers[1].toInt() + val patch = numbers[2].toInt() + var build = 0 + if (parts.size > 2) { + println(parts) + build = parts[2].toInt() + } else if (numbers.size > 3) { + build = numbers[3].toInt() + } + + val versionCode = major * 10000000 + minor * 10000000 + patch * 1000 + build + + return VersionInfo(appName, versionName, versionCode) +} + +val versionInfo = getVersionInfo(project) + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + signingConfigs { + getByName("debug") { + // inject signing config + } + } + namespace = "io.freetubeapp.freetube" + compileSdk = 34 + dataBinding { + enable = true + } + defaultConfig { + applicationId = versionInfo.appId + minSdk = 24 + targetSdk = 34 + versionCode = versionInfo.versionCode + versionName = versionInfo.version + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + viewBinding = true + } +} + +dependencies { + + implementation("com.google.android.material:material:1.11.0") + implementation("androidx.media3:media3-ui:1.2.1") + +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000000000..481bb43481410 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000000..904497895a123 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/io/freetubeapp/freetube/BackgroundPlayWebView.kt b/android/app/src/main/java/io/freetubeapp/freetube/BackgroundPlayWebView.kt new file mode 100644 index 0000000000000..6aef40d6ec44d --- /dev/null +++ b/android/app/src/main/java/io/freetubeapp/freetube/BackgroundPlayWebView.kt @@ -0,0 +1,17 @@ +package io.freetubeapp.freetube + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.webkit.WebView + +class BackgroundPlayWebView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null +) : WebView(context, attrs) { + private var once: Boolean = false + override fun onWindowVisibilityChanged(visibility: Int) { + if (once) return + if (visibility != View.GONE) super.onWindowVisibilityChanged(View.VISIBLE) + once = true + } +} diff --git a/android/app/src/main/java/io/freetubeapp/freetube/FreeTubeJavaScriptInterface.kt b/android/app/src/main/java/io/freetubeapp/freetube/FreeTubeJavaScriptInterface.kt new file mode 100644 index 0000000000000..828e42a6cd88c --- /dev/null +++ b/android/app/src/main/java/io/freetubeapp/freetube/FreeTubeJavaScriptInterface.kt @@ -0,0 +1,481 @@ +package io.freetubeapp.freetube + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Intent +import android.graphics.BitmapFactory +import android.media.MediaMetadata +import android.media.session.MediaSession +import android.media.session.PlaybackState +import android.net.Uri +import android.os.Build +import android.webkit.JavascriptInterface +import androidx.activity.result.ActivityResult +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationManagerCompat +import java.io.File +import java.io.FileInputStream +import java.net.URL +import java.net.URLEncoder +import java.util.UUID.* + +class FreeTubeJavaScriptInterface { + private var context: MainActivity + private var mediaSession: MediaSession? + private var lastPosition: Long + private var lastState: Int + private var lastNotification: Notification? = null + + companion object { + private const val DATA_DIRECTORY = "data://" + private const val CHANNEL_ID = "media_controls" + private val NOTIFICATION_ID = (2..1000).random() + private val NOTIFICATION_TAG = String.format("%s", randomUUID()) + } + + constructor(main: MainActivity) { + context = main + mediaSession = null + lastPosition = 0 + lastState = PlaybackState.STATE_PLAYING + } + + /** + * @param directory a shortened directory uri + * @return a full directory uri + */ + private fun getDirectory(directory: String): String { + val path = if (directory == DATA_DIRECTORY) { + // this is the directory cordova gave us access to before + context.getExternalFilesDir(null)!!.parent + } else { + directory + } + return path + } + + /** + * retrieves actions for the media controls + * @param state the current state of the media controls (ex PlaybackState.STATE_PLAYING or PlaybackState.STATE_PAUSED + */ + private fun getActions(state: Int = lastState): Array { + var neutralAction = arrayOf("Pause", "pause") + var neutralIcon = androidx.media3.ui.R.drawable.exo_icon_pause + if (state == PlaybackState.STATE_PAUSED) { + neutralAction = arrayOf("Play", "play") + neutralIcon = androidx.media3.ui.R.drawable.exo_icon_play + } + return arrayOf( + Notification.Action.Builder( + androidx.media3.ui.R.drawable.exo_ic_skip_previous, + "Back", + PendingIntent.getBroadcast(context, 1, Intent(context, MediaControlsReceiver::class.java).setAction("previous"), PendingIntent.FLAG_IMMUTABLE) + ).build(), + Notification.Action.Builder( + neutralIcon, + neutralAction[0], + PendingIntent.getBroadcast(context, 1, Intent(context, MediaControlsReceiver::class.java).setAction(neutralAction[1]), PendingIntent.FLAG_IMMUTABLE) + ).build(), + Notification.Action.Builder( + androidx.media3.ui.R.drawable.exo_ic_skip_next, + "Next", + PendingIntent.getBroadcast(context, 1, Intent(context, MediaControlsReceiver::class.java).setAction("next"), PendingIntent.FLAG_IMMUTABLE) + ).build() + ) + } + + /** + * retrieves the media style for the media controls notification + */ + private fun getMediaStyle(): Notification.MediaStyle? { + if (mediaSession != null) { + return Notification.MediaStyle() + .setMediaSession(mediaSession!!.sessionToken).setShowActionsInCompactView(0, 1, 2) + } else { + return null + } + } + + /** + * Gets a fresh media controls notification given the current `mediaSession` + * @param actions a list of actions for the media controls (defaults to `getActions()`) + */ + @RequiresApi(Build.VERSION_CODES.O) + private fun getMediaControlsNotification(actions: Array = getActions()): Notification? { + val mediaStyle = getMediaStyle() + if (mediaStyle != null) { + // when clicking the notification, launch the app as if the user tapped on it in their launcher (open an existing instance if able) + val notificationIntent = Intent(Intent.ACTION_MAIN) + .addCategory(Intent.CATEGORY_LAUNCHER) + .setClass(context, MainActivity::class.java) + + // always reuse notification + if (lastNotification != null) { + lastNotification!!.actions = actions + return lastNotification + } + + return Notification.Builder(context, CHANNEL_ID) + .setStyle(getMediaStyle()) + .setSmallIcon(R.drawable.ic_media_notification_icon) + .addAction( + actions[0] + ) + .addAction( + actions[1] + ) + .addAction( + actions[2] + ) + .setContentIntent( + PendingIntent.getActivity( + context, 1, notificationIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + ) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .build() + } else { + return null + } + } + + /** + * pushes a notification + * @param notification the notification the be pushed (usually a media controls notification) + */ + @SuppressLint("MissingPermission") + private fun pushNotification(notification: Notification) { + val manager = NotificationManagerCompat.from(context) + manager.notify(NOTIFICATION_TAG, NOTIFICATION_ID, notification) + lastNotification = notification + } + + /** + * sets the state of the media session + * @param session the current media session + * @param state the state of playback + * @param position the position in milliseconds of playback + */ + @SuppressLint("MissingPermission") + private fun setState(session: MediaSession, state: Int, position: Long? = null) { + + if (state != lastState) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // need to reissue a notification if we want to update the actions + var actions = getActions(state) + val notification = getMediaControlsNotification(actions) + pushNotification(notification!!) + } + } + lastState = state + var statePosition: Long + if (position == null) { + statePosition = lastPosition + } else { + statePosition = position + } + session.setPlaybackState( + PlaybackState.Builder() + .setState(state, statePosition, 1.0f) + .setActions(PlaybackState.ACTION_PLAY_PAUSE or PlaybackState.ACTION_PAUSE or PlaybackState.ACTION_SKIP_TO_NEXT or PlaybackState.ACTION_SKIP_TO_PREVIOUS or + PlaybackState.ACTION_PLAY_FROM_MEDIA_ID or + PlaybackState.ACTION_PLAY_FROM_SEARCH or PlaybackState.ACTION_SEEK_TO) + .build() + ) + } + + /** + * sets the metadata of the media session + * @param session the current media session + * @param trackName the video name + * @param artist the channel name + * @param duration duration in milliseconds + */ + @SuppressLint("MissingPermission") + @RequiresApi(Build.VERSION_CODES.O) + private fun setMetadata(session: MediaSession, trackName: String, artist: String, duration: Long, art: String?, pushNotification: Boolean = true) { + var notification: Notification? = null + if (pushNotification) { + notification = getMediaControlsNotification() + } + + if (art != null) { + // todo move this to a function and add try catch + val connection = URL(art).openConnection() + connection.connect() + val input = connection.getInputStream() + val bitmapArt = BitmapFactory.decodeStream(input) + // todo + session.setMetadata( + MediaMetadata.Builder() + .putString(MediaMetadata.METADATA_KEY_TITLE, trackName) + .putString(MediaMetadata.METADATA_KEY_ARTIST, artist) + .putBitmap(MediaMetadata.METADATA_KEY_ART, bitmapArt) + .putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, bitmapArt) + .putLong(MediaMetadata.METADATA_KEY_DURATION, duration) + .build() + ) + } else { + session.setMetadata( + MediaMetadata.Builder() + .putString(MediaMetadata.METADATA_KEY_TITLE, trackName) + .putString(MediaMetadata.METADATA_KEY_ARTIST, artist) + .putLong(MediaMetadata.METADATA_KEY_DURATION, duration) + .build() + ) + } + if (pushNotification && notification != null) { + pushNotification(notification) + } + } + + /** + * creates (or updates) a media session + * @param title the track name / video title + * @param artist the author / channel name + * @param duration the duration in milliseconds of the video + * @param thumbnail a URL to the thumbnail for the video + */ + @SuppressLint("MissingPermission") + @RequiresApi(Build.VERSION_CODES.O) + @JavascriptInterface + fun createMediaSession(title: String, artist: String, duration: Long = 0, thumbnail: String? = null) { + val notificationManager = NotificationManagerCompat.from(context) + val channel = NotificationChannel(CHANNEL_ID, "Media Controls", NotificationManager.IMPORTANCE_MIN) + channel.lockscreenVisibility = Notification.VISIBILITY_PRIVATE + notificationManager.createNotificationChannel(channel) + var session: MediaSession + + // don't create multiple sessions for one channel + // it messes with custom actions + if (mediaSession == null) { + // add the callbacks && listeners + + session = MediaSession(context, CHANNEL_ID) + session.isActive = true + mediaSession = session + session.setCallback(object : MediaSession.Callback() { + override fun onSkipToNext() { + super.onSkipToNext() + context.runOnUiThread { + context.webView.loadUrl("javascript: window.notifyMediaSessionListeners('next')") + } + } + + override fun onSkipToPrevious() { + super.onSkipToPrevious() + context.runOnUiThread { + context.webView.loadUrl("javascript: window.notifyMediaSessionListeners('previous')") + } + } + override fun onSeekTo(pos: Long) { + super.onSeekTo(pos) + lastPosition = pos + context.runOnUiThread { + context.webView.loadUrl(String.format("javascript: window.notifyMediaSessionListeners('seek', %s)", pos)) + } + } + + override fun onPlay() { + super.onPlay() + context.runOnUiThread { + context.webView.loadUrl("javascript: window.notifyMediaSessionListeners('play')") + } + } + + override fun onPause() { + super.onPause() + context.runOnUiThread { + context.webView.loadUrl("javascript: window.notifyMediaSessionListeners('pause')") + } + } + }) + } else { + session = mediaSession!! + } + + val notification = getMediaControlsNotification() + // use the set metadata function without pushing a notification + setMetadata(session, title, artist, duration, thumbnail, false) + setState(session, PlaybackState.STATE_PLAYING) + + pushNotification(notification!!) + } + + /** + * updates the state of the active media session + * @param state the state; should be an Int (as a string because the java bridge) + * @param position the position; should be a Long (as a string because the java bridge) + */ + @JavascriptInterface + fun updateMediaSessionState(state: String?, position: String? = null) { + var givenState = state?.toInt() + if (state == null) { + givenState = lastState + } else { + } + if (position != null) { + lastPosition = position.toLong()!! + } + setState(mediaSession!!, givenState!!, position?.toLong()) + } + + /** + * updates the metadata of the active media session + * @param trackName the video title + * @param artist the channel name + * @param duration the length of the video in milliseconds + * @param art the URL to the video thumbnail + */ + @SuppressLint("NewApi") + @JavascriptInterface + fun updateMediaSessionData(trackName: String, artist: String, duration: Long, art: String? = null) { + setMetadata(mediaSession!!, trackName, artist, duration, art) + } + + /** + * cancels the active media notification + */ + @JavascriptInterface + fun cancelMediaNotification() { + val manager = NotificationManagerCompat.from(context) + manager.cancel(NOTIFICATION_TAG, NOTIFICATION_ID) + } + + /** + * reads a file from storage + */ + @JavascriptInterface + fun readFile(basedir: String, filename: String): String { + try { + if (basedir.startsWith("content://")) { + val stream = context.contentResolver.openInputStream(Uri.parse(basedir)) + val content = String(stream!!.readBytes()) + stream!!.close() + return content + } + val path = getDirectory(basedir) + val file = File(path, filename) + return FileInputStream(file).bufferedReader().use { it.readText() } + } catch (ex: Exception) { + return "" + } + } + + /** + * writes a file to storage + */ + @JavascriptInterface + fun writeFile(basedir: String, filename: String, content: String): Boolean { + try { + if (basedir.startsWith("content://")) { + // urls created by save dialog + val stream = context.contentResolver.openOutputStream(Uri.parse(basedir), "wt") + stream!!.write(content.toByteArray()) + stream!!.flush() + stream!!.close() + return true + } + val path = getDirectory(basedir) + var file = File(path, filename) + if (!file.exists()) { + file.createNewFile() + } + file.writeText(content) + return true + } catch (ex: Exception) { + return false + } + } + + /** + * requests a save dialog, resolves a js promise when done, resolves with `USER_CANCELED` if the user cancels + * @return a js promise id + */ + @JavascriptInterface + fun requestSaveDialog(fileName: String, fileType: String): String { + val promise = jsPromise() + val saveDialogIntent = Intent(Intent.ACTION_CREATE_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType(fileType) + .putExtra(Intent.EXTRA_TITLE, fileName) + context.listenForActivityResults { + result: ActivityResult? -> + if (result!!.resultCode == Activity.RESULT_CANCELED) { + resolve(promise, "USER_CANCELED") + } + try { + val uri = result!!.data!!.data + var stringUri = uri.toString() + // something about the java bridge url decodes all strings, so I am going to double encode this one + resolve(promise, "{ \"uri\": \"${URLEncoder.encode(stringUri, "utf-8")}\" }") + } catch (ex: Exception) { + reject(promise, ex.toString()) + } + } + context.activityResultLauncher.launch(saveDialogIntent) + return promise + } + @JavascriptInterface + fun requestOpenDialog(fileTypes: String): String { + val promise = jsPromise() + val openDialogIntent = Intent(Intent.ACTION_GET_CONTENT) + .setType("*/*") + .putExtra(Intent.EXTRA_MIME_TYPES, fileTypes.split(",").toTypedArray()) + + context.listenForActivityResults { + result: ActivityResult? -> + if (result!!.resultCode == Activity.RESULT_CANCELED) { + resolve(promise, "USER_CANCELED") + } + try { + val uri = result!!.data!!.data + var mimeType = context.contentResolver.getType(uri!!) + + resolve(promise, "{ \"uri\": \"${URLEncoder.encode(uri.toString(), "utf-8")}\", \"type\": \"${mimeType}\" }") + } catch (ex: Exception) { + reject(promise, ex.toString()) + } + } + context.activityResultLauncher.launch(openDialogIntent) + return promise + } + + /** + * notifies the context that the js is loaded && ready + */ + @JavascriptInterface + fun notifyReady() { + context.isReady = true + } + + /** + * @return the id of a promise on the window + */ + private fun jsPromise(): String { + val id = "${randomUUID()}" + context.runOnUiThread { + context.webView.loadUrl("javascript: window['${id}'] = {}; window['${id}'].promise = new Promise((resolve, reject) => { window['${id}'].resolve = resolve; window['${id}'].reject = reject })") + } + return id + } + + /** + * resolves a js promise given the id + */ + private fun resolve(id: String, message: String) { + context.webView.loadUrl("javascript: window['${id}'].resolve(`${message}`)") + } + + /** + * rejects a js promise given the id + */ + private fun reject(id: String, message: String) { + context.webView.loadUrl("javascript: window['${id}'].reject(new Error(\"${message}\"))") + } +} diff --git a/android/app/src/main/java/io/freetubeapp/freetube/KeepAliveService.kt b/android/app/src/main/java/io/freetubeapp/freetube/KeepAliveService.kt new file mode 100644 index 0000000000000..f3e7e2225039a --- /dev/null +++ b/android/app/src/main/java/io/freetubeapp/freetube/KeepAliveService.kt @@ -0,0 +1,55 @@ +package io.freetubeapp.freetube + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.ComponentName +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationManagerCompat + + +class KeepAliveService : Service() { + companion object { + private val CHANNEL_ID = "keep_alive" + } + override fun onBind(intent: Intent?): IBinder? { + TODO("Not yet implemented") + } + override fun onCreate() { + super.onCreate() + val notificationManager = NotificationManagerCompat.from(applicationContext) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel(CHANNEL_ID, "Keep Alive", NotificationManager.IMPORTANCE_MIN) + notificationManager.createNotificationChannel(channel) + } else { + val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManager.IMPORTANCE_MIN).build() + notificationManager.createNotificationChannel(channel) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForeground(1, + Notification.Builder(this.applicationContext, CHANNEL_ID) + .setContentTitle("FreeTube is running in the background.") + .setCategory(Notification.CATEGORY_SERVICE) + .setSmallIcon(R.drawable.ic_media_notification_icon) + .build()) + } else { + startForeground(1, + Notification.Builder(this.applicationContext) + .setContentTitle("FreeTube is running in the background.") + .setCategory(Notification.CATEGORY_SERVICE) + .build()) + } + } + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + return START_STICKY + } + override fun startForegroundService(service: Intent?): ComponentName? { + return super.startForegroundService(service) + } +} diff --git a/android/app/src/main/java/io/freetubeapp/freetube/MainActivity.kt b/android/app/src/main/java/io/freetubeapp/freetube/MainActivity.kt new file mode 100644 index 0000000000000..bc4945cb3371b --- /dev/null +++ b/android/app/src/main/java/io/freetubeapp/freetube/MainActivity.kt @@ -0,0 +1,251 @@ +package io.freetubeapp.freetube + +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver +import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.FrameLayout +import androidx.activity.addCallback +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat.OnRequestPermissionsResultCallback +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import io.freetubeapp.freetube.databinding.ActivityMainBinding +import java.net.URLEncoder + + +class MainActivity : AppCompatActivity(), OnRequestPermissionsResultCallback { + + private lateinit var binding: ActivityMainBinding + private lateinit var permissionsListeners: MutableList<(Int, Array, IntArray) -> Unit> + private lateinit var activityResultListeners: MutableList<(ActivityResult?) -> Unit> + private lateinit var keepAliveService: KeepAliveService + private lateinit var keepAliveIntent: Intent + private var fullscreenView: View? = null + lateinit var webView: BackgroundPlayWebView + lateinit var jsInterface: FreeTubeJavaScriptInterface + lateinit var activityResultLauncher: ActivityResultLauncher + var isReady: Boolean = false + @Suppress("DEPRECATION") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val content: View = findViewById(android.R.id.content) + content.viewTreeObserver.addOnPreDrawListener( + object : ViewTreeObserver.OnPreDrawListener { + override fun onPreDraw(): Boolean { + // Check whether the initial data is ready. + return if (isReady) { + // The content is ready. Start drawing. + content.viewTreeObserver.removeOnPreDrawListener(this) + true + } else { + // The content isn't ready. Suspend. + false + } + } + } + ) + + + activityResultListeners = mutableListOf() + + activityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + for (listener in activityResultListeners) { + listener(it) + } + // clear the listeners + activityResultListeners = mutableListOf() + } + + MediaControlsReceiver.notifyMediaSessionListeners = { + action -> + webView.loadUrl(String.format("javascript: window.notifyMediaSessionListeners('%s')", action)) + } + // this keeps android from shutting off the app to conserve battery + keepAliveService = KeepAliveService() + keepAliveIntent = Intent(this, keepAliveService.javaClass) + startService(keepAliveIntent) + + // this gets the controller for hiding and showing the system bars + WindowCompat.setDecorFitsSystemWindows(window, false) + val windowInsetsController = + WindowCompat.getInsetsController(window, window.decorView) + windowInsetsController.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + + // initialize the list of listeners for permissions handlers + permissionsListeners = arrayOf<(Int, Array, IntArray) -> Unit>().toMutableList() + + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + webView = binding.webView + + // bind the back button to the web-view history + onBackPressedDispatcher.addCallback { + if (webView.canGoBack()) { + webView.goBack() + } else { + this@MainActivity.moveTaskToBack(true) + } + } + + webView.settings.javaScriptEnabled = true + + // this is the 🥃 special sauce that makes local api streaming a possibility + webView.settings.allowUniversalAccessFromFileURLs = true + webView.settings.allowFileAccessFromFileURLs = true + // allow playlist ▶auto-play in background + webView.settings.mediaPlaybackRequiresUserGesture = false + + jsInterface = FreeTubeJavaScriptInterface(this) + webView.addJavascriptInterface(jsInterface, "Android") + webView.webChromeClient = object: WebChromeClient() { + + override fun onShowCustomView(view: View?, callback: CustomViewCallback?) { + windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()) + fullscreenView = view!! + view.layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + this@MainActivity.binding.root.addView(view) + webView.visibility = View.GONE + this@MainActivity.binding.root.fitsSystemWindows = false + } + + override fun onHideCustomView() { + webView.visibility = View.VISIBLE + this@MainActivity.binding.root.removeView(fullscreenView) + fullscreenView = null + windowInsetsController.show(WindowInsetsCompat.Type.systemBars()) + this@MainActivity.binding.root.fitsSystemWindows = true + } + } + webView.webViewClient = object: WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { + if (request!!.url!!.scheme == "file") { + // don't send file url requests to a web browser (it will crash the app) + return true + } + val regex = """^https?:\/\/((www\.)?youtube\.com(\/embed)?|youtu\.be)\/.*$""" + + if (Regex(regex).containsMatchIn(request!!.url!!.toString())) { + webView.loadUrl("javascript: window.notifyYoutubeLinkHandlers(\"${request!!.url}\")") + return true + } + // send all requests to a real web browser + val intent = Intent(Intent.ACTION_VIEW, request!!.url) + this@MainActivity.startActivity(intent) + return true + } + override fun onPageFinished(view: WebView?, url: String?) { + webView.loadUrl( + "javascript: window.mediaSessionListeners = window.mediaSessionListeners || {};" + + "window.addMediaSessionEventListener = function (eventName, listener) {" + + " if (!(eventName in window.mediaSessionListeners)) {" + + " window.mediaSessionListeners[eventName] = [];" + + " }" + + " window.mediaSessionListeners[eventName].push(listener);" + + "};" + + "window.notifyMediaSessionListeners = function (eventName, ...message) {" + + "if ((eventName in window.mediaSessionListeners)) {" + + " window.mediaSessionListeners[eventName].forEach(listener => listener(...message));" + + " }" + + "};" + + "window.clearAllMediaSessionEventListeners = function () {" + + " window.mediaSessionListeners = {}" + + "};" + + "window.awaitAsyncResult = function (id) {" + + " return new Promise((resolve, reject) => {" + + " const interval = setInterval(async () => {" + + " if (id in window) {" + + " clearInterval(interval);" + + " try {" + + " const result = await window[id].promise;" + + " resolve(result)" + + " } catch (ex) {" + + " reject(ex)" + + " }" + + " }" + + " }, 1)" + + " }) " + + "};" + + "window.youtubeLinkHandlers = window.youtubeLinkHandlers || [];" + + "window.addYoutubeLinkHandler = function (handler) {" + + " const i = window.youtubeLinkHandlers.length;" + + " window.youtubeLinkHandlers.push(handler);" + + " return i" + + "};" + + "window.notifyYoutubeLinkHandlers = function (message) {" + + " window.youtubeLinkHandlers.forEach((handler) => handler(message))" + + "}" + ) + super.onPageFinished(view, url) + } + } + if (intent!!.data !== null) { + val url = intent!!.data.toString() + val host = intent!!.data!!.host.toString() + val intentPath = if (host != "youtube.com" && host != "youtu.be" && host != "m.youtube.com" && host != "www.youtube.com") { + url.replace("${intent!!.data!!.host}", "youtube.com") + } else { + url + } + val intentEncoded = URLEncoder.encode(intentPath) + webView.loadUrl("file:///android_asset/index.html?intent=${intentEncoded}") + } else { + webView.loadUrl("file:///android_asset/index.html") + } + } + + fun listenForPermissionsCallbacks(listener: (Int, Array, IntArray) -> Unit) { + permissionsListeners.add(listener) + } + fun listenForActivityResults(listener: (ActivityResult?) -> Unit) { + activityResultListeners.add(listener) + } + + override fun onRequestPermissionsResult( + requestCode: Int, permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + permissionsListeners.forEach { + it(requestCode, permissions, grantResults) + } + permissionsListeners.clear() + } + + /** + * handles new intents which involve deep links (aka supported links) + */ + @SuppressLint("MissingSuperCall") + override fun onNewIntent(intent: Intent?) { + if (intent!!.data !== null) { + val uri = intent!!.data + val isYT = + uri!!.host!! == "www.youtube.com" || uri!!.host!! == "youtube.com" || uri!!.host!! == "m.youtube.com" || uri!!.host!! == "youtu.be" + val url = if (!isYT) { + uri.toString().replace(uri.host.toString(), "www.youtube.com") + } else { + uri + } + webView.loadUrl("javascript: window.notifyYoutubeLinkHandlers(\"${url}\")") + } + } + + override fun onDestroy() { + super.onDestroy() + stopService(keepAliveIntent) + jsInterface.cancelMediaNotification() + webView.destroy() + } +} diff --git a/android/app/src/main/java/io/freetubeapp/freetube/MediaControlsReceiver.kt b/android/app/src/main/java/io/freetubeapp/freetube/MediaControlsReceiver.kt new file mode 100644 index 0000000000000..66883deae27a7 --- /dev/null +++ b/android/app/src/main/java/io/freetubeapp/freetube/MediaControlsReceiver.kt @@ -0,0 +1,20 @@ +package io.freetubeapp.freetube + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +open class MediaControlsReceiver : BroadcastReceiver { + + constructor() { + } + companion object Static { + lateinit var notifyMediaSessionListeners: (String) -> Unit + } + + + override fun onReceive(context: Context?, intent: Intent?) { + val action = intent!!.action + notifyMediaSessionListeners(action!!) + } +} diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000000..a4453903f4284 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000000000..0d5f3fc8f4521 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_media_notification_icon.xml b/android/app/src/main/res/drawable/ic_media_notification_icon.xml new file mode 100644 index 0000000000000..2d2afb0d9c444 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_media_notification_icon.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000000000..8e15eda546c9a --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000000..6f3b755bf50c6 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000000..6f3b755bf50c6 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000..c209e78ecd372 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000..b2dfe3d1ba5cf Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000000..4f0f1d64e58ba Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000..62b611da08167 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000000..948a3070fe34c Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000..1b9a6956b3acd Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000000000..28d4b77f9f036 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000..9287f5083623b Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000000000..aa7d6427e6fa1 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000..9126ae37cbc35 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/values-land/dimens.xml b/android/app/src/main/res/values-land/dimens.xml new file mode 100644 index 0000000000000..22d7f004329e4 --- /dev/null +++ b/android/app/src/main/res/values-land/dimens.xml @@ -0,0 +1,3 @@ + + 48dp + \ No newline at end of file diff --git a/android/app/src/main/res/values-night/themes.xml b/android/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000000000..755d0f1566795 --- /dev/null +++ b/android/app/src/main/res/values-night/themes.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/android/app/src/main/res/values-v23/themes.xml b/android/app/src/main/res/values-v23/themes.xml new file mode 100644 index 0000000000000..d4b2a34101713 --- /dev/null +++ b/android/app/src/main/res/values-v23/themes.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/android/app/src/main/res/values-w1240dp/dimens.xml b/android/app/src/main/res/values-w1240dp/dimens.xml new file mode 100644 index 0000000000000..d73f4a359b5bb --- /dev/null +++ b/android/app/src/main/res/values-w1240dp/dimens.xml @@ -0,0 +1,3 @@ + + 200dp + \ No newline at end of file diff --git a/android/app/src/main/res/values-w600dp/dimens.xml b/android/app/src/main/res/values-w600dp/dimens.xml new file mode 100644 index 0000000000000..22d7f004329e4 --- /dev/null +++ b/android/app/src/main/res/values-w600dp/dimens.xml @@ -0,0 +1,3 @@ + + 48dp + \ No newline at end of file diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000000000..c8524cd961d27 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/android/app/src/main/res/values/dimens.xml b/android/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000000000..125df87119190 --- /dev/null +++ b/android/app/src/main/res/values/dimens.xml @@ -0,0 +1,3 @@ + + 16dp + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000000000..83b6bf5d48cc2 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + FreeTube Android + \ No newline at end of file diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000000000..f13763d8f832d --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + +