diff --git a/.github/tg.py b/.github/tg.py index 0c99f83ba..77edb7b29 100644 --- a/.github/tg.py +++ b/.github/tg.py @@ -1,33 +1,34 @@ -import telegram -from tgconfig import * -from json import load -import multiprocessing -from os import system -from time import sleep as wait - -def deploy(): - system(f'./exec --local --api-id={TG_API_ID} --api-hash={TG_API_HASH}') - -def bot(): - wait(10) - f = open('commit.json') - data = load(f) - f.close() - - bot = telegram.Bot(TG_TOKEN, base_url="http://0.0.0.0:8081/bot") - bot.send_photo(TG_POST_ID, open('alpha.png', 'rb'), f'''*Libretube {data['sha'][0:7]} // Alpha* - -[{data['commit']['message']}]({data['html_url']}) - -Signed-off-by: {data['commit']['author']['name']} -''', parse_mode=telegram.ParseMode.MARKDOWN) - bot.send_media_group(TG_POST_ID, [telegram.InputMediaDocument(open('app-universal-debug.apk', 'rb')), telegram.InputMediaDocument(open('app-x86-debug.apk', 'rb')), telegram.InputMediaDocument(open('app-x86_64-debug.apk', 'rb')), telegram.InputMediaDocument(open('app-armeabi-v7a-debug.apk', 'rb')), telegram.InputMediaDocument(open('app-arm64-v8a-debug.apk', 'rb'))]) - system('killall -9 python') - -if __name__ == '__main__': - multideploy = multiprocessing.Process(target=deploy) - multibot = multiprocessing.Process(target=bot) - multideploy.start() - multibot.start() - multideploy.join() - multibot.join() +import asyncio +from json import load +from os import listdir + +from pyrogram import Client +from pyrogram.types import InputMediaDocument +from tgconfig import * + +files = listdir() + +mediadocuments = [ + InputMediaDocument(file) for file in files if file.endswith("signed.apk") +] + +with open("commit.json") as f: + data = load(f) + +caption = f"""**Libretube {data['sha'][0:7]} // Alpha** + +{data['commit']['message']} + +Signed-off-by: {data['commit']['author']['name']} +""" + + +async def main(): + async with Client("libretube", TG_API_ID, TG_API_HASH, bot_token=TG_TOKEN) as app: + await app.send_photo( + int(TG_POST_ID), "https://libre-tube.github.io/images/Alpha.png", caption + ) + await app.send_media_group(int(TG_POST_ID), mediadocuments) + + +asyncio.run(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c86d6a6c6..248a8c89c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: steps: - uses: actions/checkout@v3 - uses: gradle/wrapper-validation-action@v1 - - uses: actions/setup-python@v3 + - uses: actions/setup-python@v4 with: python-version: '3.x' # Version range or exact version of a Python version to use, using SemVer's version range syntax architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified @@ -54,6 +54,18 @@ jobs: cd .. ./gradlew assembleDebug + - name: Sign Apk + continue-on-error: true + id: sign_apk + uses: ilharp/sign-android-release@v1 + with: + releaseDir: app/build/outputs/apk/debug + signingKey: ${{ secrets.ANDROID_SIGNING_KEY }} + keyAlias: ${{ secrets.ANDROID_KEY_ALIAS }} + keyStorePassword: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + keyPassword: ${{ secrets.ANDROID_KEY_PASSWORD }} + + - name: Upload to Archive continue-on-error: true run: | @@ -61,7 +73,7 @@ jobs: echo "GH_REPO = '${{ github.repository }}'" > tgconfig.py git clone https://github.com/LibreTubeAlpha/Archive archive rm -rf archive/*.apk - mv app/build/outputs/apk/debug/*.apk archive/ + mv app/build/outputs/apk/debug/*-signed.apk archive/ cd archive python ../uploader.py @@ -69,15 +81,12 @@ jobs: continue-on-error: true run: | cd archive - curl https://libre-tube.github.io/images/Alpha.png --output alpha.png - chmod 755 ./exec mv ../tgconfig.py . echo "TG_TOKEN = '${{ secrets.TG_TOKEN }}'" >> tgconfig.py echo "TG_API_ID = '${{ secrets.TG_API_ID }}'" >> tgconfig.py echo "TG_POST_ID = '${{ secrets.TG_POST_ID }}'" >> tgconfig.py echo "TG_API_HASH = '${{ secrets.TG_API_HASH }}'" >> tgconfig.py - python -m pip install --upgrade pip - pip install python-telegram-bot + python -m pip install --upgrade pip TgCrypto Pyrogram mv ../.github/tg.py . mv ../.github/commit.json . python tg.py @@ -86,4 +95,5 @@ jobs: uses: actions/upload-artifact@v3 with: name: app - path: archive/*.apk + path: archive/*-signed.apk + diff --git a/README.md b/README.md index a112cfb4f..6b870061c 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ If creating a pull request, please make sure to format your code (preferred ktli Translation status -## 🌗 Differences from NewPipe +## 🌗 Differences to NewPipe With NewPipe, the extraction is done locally on your phone, and all the requests sent towards YouTube/Google are done directly from the network you're connected to, which doesn't use a middleman server in between. Therefore, Google can still access information such as the user's IP address. Aside from that, subscriptions can only be stored locally. diff --git a/ROADMAP.md b/ROADMAP.md index c2dea6b39..bc083c42f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,8 +6,7 @@ This represents the larger, bigger impact features and enhancements we have plan Feel free to help us if you have any knowledge concerning the following planned features or anything else you imagine. ## Planned -- Support for local playlists -- Various smaller features +- Currently only various smaller features ## Not planned - Google/MicroG Login diff --git a/app/build.gradle b/app/build.gradle index 6b2a3c870..9b32ddf4f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -13,11 +13,17 @@ android { applicationId 'com.github.libretube' minSdk 21 targetSdk 33 - versionCode 20 - versionName '0.6.1' + versionCode 23 + versionName '0.8.0' multiDexEnabled true testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' resValue "string", "app_name", "LibreTube" + + javaCompileOptions { + annotationProcessorOptions { + arguments += ["room.schemaLocation": "$projectDir/schemas".toString()] + } + } } buildFeatures { @@ -41,6 +47,8 @@ android { } debug { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' debuggable true applicationIdSuffix ".debug" resValue "string", "app_name", "LibreTube Debug" @@ -101,6 +109,7 @@ dependencies { implementation libs.exoplayer implementation(libs.exoplayer.extension.cronet) { exclude group: 'com.google.android.gms' } implementation libs.exoplayer.extension.mediasession + implementation libs.exoplayer.dash /* Retrofit and Jackson */ implementation libs.square.retrofit diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index c035dfb82..db9143d05 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -16,15 +16,16 @@ # debugging stack traces. -keepattributes SourceFile,LineNumberTable +# prevents obfuscation in debug logs +-dontobfuscate + # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile + #uncomment for debug #-keepnames class ** + +# Keep data classes used for Retrofit -keep class com.github.libretube.obj.** { *; } - -# prevents android from removing it --keep class com.github.libretube.obj.**.** { *; } - -# prevents obfuscation in debug logs --dontobfuscate \ No newline at end of file +-keep class com.github.libretube.obj.update.** { *; } diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json index e1a3264e9..7e8abec17 100644 --- a/app/release/output-metadata.json +++ b/app/release/output-metadata.json @@ -11,36 +11,10 @@ "type": "UNIVERSAL", "filters": [], "attributes": [], - "versionCode": 20, - "versionName": "0.6.1", + "versionCode": 23, + "versionName": "0.8.0", "outputFile": "app-universal-release.apk" }, - { - "type": "ONE_OF_MANY", - "filters": [ - { - "filterType": "ABI", - "value": "arm64-v8a" - } - ], - "attributes": [], - "versionCode": 20, - "versionName": "0.6.1", - "outputFile": "app-arm64-v8a-release.apk" - }, - { - "type": "ONE_OF_MANY", - "filters": [ - { - "filterType": "ABI", - "value": "armeabi-v7a" - } - ], - "attributes": [], - "versionCode": 20, - "versionName": "0.6.1", - "outputFile": "app-armeabi-v7a-release.apk" - }, { "type": "ONE_OF_MANY", "filters": [ @@ -50,10 +24,23 @@ } ], "attributes": [], - "versionCode": 20, - "versionName": "0.6.1", + "versionCode": 23, + "versionName": "0.8.0", "outputFile": "app-x86-release.apk" }, + { + "type": "ONE_OF_MANY", + "filters": [ + { + "filterType": "ABI", + "value": "arm64-v8a" + } + ], + "attributes": [], + "versionCode": 23, + "versionName": "0.8.0", + "outputFile": "app-arm64-v8a-release.apk" + }, { "type": "ONE_OF_MANY", "filters": [ @@ -63,9 +50,22 @@ } ], "attributes": [], - "versionCode": 20, - "versionName": "0.6.1", + "versionCode": 23, + "versionName": "0.8.0", "outputFile": "app-x86_64-release.apk" + }, + { + "type": "ONE_OF_MANY", + "filters": [ + { + "filterType": "ABI", + "value": "armeabi-v7a" + } + ], + "attributes": [], + "versionCode": 23, + "versionName": "0.8.0", + "outputFile": "app-armeabi-v7a-release.apk" } ], "elementType": "File" diff --git a/app/schemas/com.github.libretube.db.AppDatabase/7.json b/app/schemas/com.github.libretube.db.AppDatabase/7.json new file mode 100644 index 000000000..17bde0ca2 --- /dev/null +++ b/app/schemas/com.github.libretube.db.AppDatabase/7.json @@ -0,0 +1,174 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "c9803a67ce206dbda6e44ed761f80136", + "entities": [ + { + "tableName": "watchHistoryItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `title` TEXT, `uploadDate` TEXT, `uploader` TEXT, `uploaderUrl` TEXT, `uploaderAvatar` TEXT, `thumbnailUrl` TEXT, `duration` INTEGER, PRIMARY KEY(`videoId`))", + "fields": [ + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadDate", + "columnName": "uploadDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploaderUrl", + "columnName": "uploaderUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploaderAvatar", + "columnName": "uploaderAvatar", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "videoId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "watchPosition", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`videoId`))", + "fields": [ + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "videoId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "searchHistoryItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, PRIMARY KEY(`query`))", + "fields": [ + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "query" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "customInstance", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `apiUrl` TEXT NOT NULL, `frontendUrl` TEXT NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiUrl", + "columnName": "apiUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "frontendUrl", + "columnName": "frontendUrl", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "name" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "localSubscription", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`channelId` TEXT NOT NULL, PRIMARY KEY(`channelId`))", + "fields": [ + { + "fieldPath": "channelId", + "columnName": "channelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "channelId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c9803a67ce206dbda6e44ed761f80136')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.github.libretube.db.AppDatabase/8.json b/app/schemas/com.github.libretube.db.AppDatabase/8.json new file mode 100644 index 000000000..5d8465e8c --- /dev/null +++ b/app/schemas/com.github.libretube.db.AppDatabase/8.json @@ -0,0 +1,224 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "eb8d0ff1131448df6216b549bbfa7c21", + "entities": [ + { + "tableName": "watchHistoryItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `title` TEXT, `uploadDate` TEXT, `uploader` TEXT, `uploaderUrl` TEXT, `uploaderAvatar` TEXT, `thumbnailUrl` TEXT, `duration` INTEGER, PRIMARY KEY(`videoId`))", + "fields": [ + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadDate", + "columnName": "uploadDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploaderUrl", + "columnName": "uploaderUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploaderAvatar", + "columnName": "uploaderAvatar", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "videoId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "watchPosition", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`videoId`))", + "fields": [ + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "videoId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "searchHistoryItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, PRIMARY KEY(`query`))", + "fields": [ + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "query" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "customInstance", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `apiUrl` TEXT NOT NULL, `frontendUrl` TEXT NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiUrl", + "columnName": "apiUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "frontendUrl", + "columnName": "frontendUrl", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "name" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "localSubscription", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`channelId` TEXT NOT NULL, PRIMARY KEY(`channelId`))", + "fields": [ + { + "fieldPath": "channelId", + "columnName": "channelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "channelId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlistBookmark", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistId` TEXT NOT NULL, `playlistName` TEXT, `thumbnailUrl` TEXT, `uploader` TEXT, `uploaderUrl` TEXT, `uploaderAvatar` TEXT, PRIMARY KEY(`playlistId`))", + "fields": [ + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistName", + "columnName": "playlistName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploaderUrl", + "columnName": "uploaderUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploaderAvatar", + "columnName": "uploaderAvatar", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "playlistId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'eb8d0ff1131448df6216b549bbfa7c21')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.github.libretube.db.AppDatabase/9.json b/app/schemas/com.github.libretube.db.AppDatabase/9.json new file mode 100644 index 000000000..b1129fef3 --- /dev/null +++ b/app/schemas/com.github.libretube.db.AppDatabase/9.json @@ -0,0 +1,330 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "8c1e428cb526415347639e49f7757f76", + "entities": [ + { + "tableName": "watchHistoryItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `title` TEXT, `uploadDate` TEXT, `uploader` TEXT, `uploaderUrl` TEXT, `uploaderAvatar` TEXT, `thumbnailUrl` TEXT, `duration` INTEGER, PRIMARY KEY(`videoId`))", + "fields": [ + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadDate", + "columnName": "uploadDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploaderUrl", + "columnName": "uploaderUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploaderAvatar", + "columnName": "uploaderAvatar", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "videoId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "watchPosition", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`videoId`))", + "fields": [ + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "videoId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "searchHistoryItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, PRIMARY KEY(`query`))", + "fields": [ + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "query" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "customInstance", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `apiUrl` TEXT NOT NULL, `frontendUrl` TEXT NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiUrl", + "columnName": "apiUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "frontendUrl", + "columnName": "frontendUrl", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "name" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "localSubscription", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`channelId` TEXT NOT NULL, PRIMARY KEY(`channelId`))", + "fields": [ + { + "fieldPath": "channelId", + "columnName": "channelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "channelId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlistBookmark", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistId` TEXT NOT NULL, `playlistName` TEXT, `thumbnailUrl` TEXT, `uploader` TEXT, `uploaderUrl` TEXT, `uploaderAvatar` TEXT, PRIMARY KEY(`playlistId`))", + "fields": [ + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistName", + "columnName": "playlistName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploaderUrl", + "columnName": "uploaderUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploaderAvatar", + "columnName": "uploaderAvatar", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "playlistId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "LocalPlaylist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "LocalPlaylistItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` INTEGER NOT NULL, `videoId` TEXT NOT NULL, `title` TEXT, `uploadDate` TEXT, `uploader` TEXT, `uploaderUrl` TEXT, `uploaderAvatar` TEXT, `thumbnailUrl` TEXT, `duration` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadDate", + "columnName": "uploadDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploaderUrl", + "columnName": "uploaderUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploaderAvatar", + "columnName": "uploaderAvatar", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8c1e428cb526415347639e49f7757f76')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 02a10b7ca..925a61dcf 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,13 @@ xmlns:tools="http://schemas.android.com/tools" android:installLocation="auto"> + + + @@ -20,7 +27,8 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/StartupTheme" - tools:targetApi="n"> + tools:targetApi="n" + android:banner="@mipmap/ic_launcher"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -329,4 +385,4 @@ android:exported="false" /> - \ No newline at end of file + diff --git a/app/src/main/ic_launcher_light-playstore.png b/app/src/main/ic_launcher_light-playstore.png new file mode 100644 index 000000000..df4f382f6 Binary files /dev/null and b/app/src/main/ic_launcher_light-playstore.png differ diff --git a/app/src/main/java/com/github/libretube/LibreTubeApp.kt b/app/src/main/java/com/github/libretube/LibreTubeApp.kt index 77212a998..dbd4db631 100644 --- a/app/src/main/java/com/github/libretube/LibreTubeApp.kt +++ b/app/src/main/java/com/github/libretube/LibreTubeApp.kt @@ -16,6 +16,7 @@ import com.github.libretube.util.ExceptionHandler import com.github.libretube.util.ImageHelper import com.github.libretube.util.NotificationHelper import com.github.libretube.util.PreferenceHelper +import com.github.libretube.util.ProxyHelper class LibreTubeApp : Application() { override fun onCreate() { @@ -52,10 +53,16 @@ class LibreTubeApp : Application() { /** * Initialize the notification listener in the background */ - NotificationHelper(this).enqueueWork( + NotificationHelper.enqueueWork( + context = this, existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.KEEP ) + /** + * Fetch the image proxy URL for local playlists and the watch history + */ + ProxyHelper.fetchProxyUrl() + /** * Handler for uncaught exceptions */ diff --git a/app/src/main/java/com/github/libretube/api/PipedApi.kt b/app/src/main/java/com/github/libretube/api/PipedApi.kt index a6eb42c6d..cfdf4457b 100644 --- a/app/src/main/java/com/github/libretube/api/PipedApi.kt +++ b/app/src/main/java/com/github/libretube/api/PipedApi.kt @@ -1,5 +1,22 @@ package com.github.libretube.api +import com.github.libretube.api.obj.Channel +import com.github.libretube.api.obj.ChannelTabResponse +import com.github.libretube.api.obj.CommentsPage +import com.github.libretube.api.obj.DeleteUserRequest +import com.github.libretube.api.obj.Login +import com.github.libretube.api.obj.Message +import com.github.libretube.api.obj.PipedConfig +import com.github.libretube.api.obj.Playlist +import com.github.libretube.api.obj.PlaylistId +import com.github.libretube.api.obj.Playlists +import com.github.libretube.api.obj.SearchResult +import com.github.libretube.api.obj.SegmentData +import com.github.libretube.api.obj.StreamItem +import com.github.libretube.api.obj.Streams +import com.github.libretube.api.obj.Subscribe +import com.github.libretube.api.obj.Subscription +import com.github.libretube.api.obj.Token import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.Header @@ -8,81 +25,90 @@ import retrofit2.http.Path import retrofit2.http.Query interface PipedApi { + @GET("config") + suspend fun getConfig(): PipedConfig + @GET("trending") - suspend fun getTrending(@Query("region") region: String): List + suspend fun getTrending(@Query("region") region: String): List @GET("streams/{videoId}") - suspend fun getStreams(@Path("videoId") videoId: String): com.github.libretube.api.obj.Streams + suspend fun getStreams(@Path("videoId") videoId: String): Streams @GET("comments/{videoId}") - suspend fun getComments(@Path("videoId") videoId: String): com.github.libretube.api.obj.CommentsPage + suspend fun getComments(@Path("videoId") videoId: String): CommentsPage @GET("sponsors/{videoId}") suspend fun getSegments( @Path("videoId") videoId: String, @Query("category") category: String - ): com.github.libretube.api.obj.Segments + ): SegmentData @GET("nextpage/comments/{videoId}") suspend fun getCommentsNextPage( @Path("videoId") videoId: String, @Query("nextpage") nextPage: String - ): com.github.libretube.api.obj.CommentsPage + ): CommentsPage @GET("search") suspend fun getSearchResults( @Query("q") searchQuery: String, @Query("filter") filter: String - ): com.github.libretube.api.obj.SearchResult + ): SearchResult @GET("nextpage/search") suspend fun getSearchResultsNextPage( @Query("q") searchQuery: String, @Query("filter") filter: String, @Query("nextpage") nextPage: String - ): com.github.libretube.api.obj.SearchResult + ): SearchResult @GET("suggestions") suspend fun getSuggestions(@Query("query") query: String): List @GET("channel/{channelId}") - suspend fun getChannel(@Path("channelId") channelId: String): com.github.libretube.api.obj.Channel + suspend fun getChannel(@Path("channelId") channelId: String): Channel + + @GET("channels/tabs") + suspend fun getChannelTab( + @Query("data") data: String, + @Query("nextpage") nextPage: String? = null + ): ChannelTabResponse @GET("user/{name}") - suspend fun getChannelByName(@Path("name") channelName: String): com.github.libretube.api.obj.Channel + suspend fun getChannelByName(@Path("name") channelName: String): Channel @GET("nextpage/channel/{channelId}") suspend fun getChannelNextPage( @Path("channelId") channelId: String, @Query("nextpage") nextPage: String - ): com.github.libretube.api.obj.Channel + ): Channel @GET("playlists/{playlistId}") - suspend fun getPlaylist(@Path("playlistId") playlistId: String): com.github.libretube.api.obj.Playlist + suspend fun getPlaylist(@Path("playlistId") playlistId: String): Playlist @GET("nextpage/playlists/{playlistId}") suspend fun getPlaylistNextPage( @Path("playlistId") playlistId: String, @Query("nextpage") nextPage: String - ): com.github.libretube.api.obj.Playlist + ): Playlist @POST("login") - suspend fun login(@Body login: com.github.libretube.api.obj.Login): com.github.libretube.api.obj.Token + suspend fun login(@Body login: Login): Token @POST("register") - suspend fun register(@Body login: com.github.libretube.api.obj.Login): com.github.libretube.api.obj.Token + suspend fun register(@Body login: Login): Token @POST("user/delete") suspend fun deleteAccount( @Header("Authorization") token: String, - @Body password: com.github.libretube.api.obj.DeleteUserRequest + @Body password: DeleteUserRequest ) @GET("feed") - suspend fun getFeed(@Query("authToken") token: String?): List + suspend fun getFeed(@Query("authToken") token: String?): List @GET("feed/unauthenticated") - suspend fun getUnauthenticatedFeed(@Query("channels") channels: String): List + suspend fun getUnauthenticatedFeed(@Query("channels") channels: String): List @GET("subscribed") suspend fun isSubscribed( @@ -91,66 +117,66 @@ interface PipedApi { ): com.github.libretube.api.obj.Subscribed @GET("subscriptions") - suspend fun subscriptions(@Header("Authorization") token: String): List + suspend fun subscriptions(@Header("Authorization") token: String): List @GET("subscriptions/unauthenticated") - suspend fun unauthenticatedSubscriptions(@Query("channels") channels: String): List + suspend fun unauthenticatedSubscriptions(@Query("channels") channels: String): List @POST("subscribe") suspend fun subscribe( @Header("Authorization") token: String, - @Body subscribe: com.github.libretube.api.obj.Subscribe - ): com.github.libretube.api.obj.Message + @Body subscribe: Subscribe + ): Message @POST("unsubscribe") suspend fun unsubscribe( @Header("Authorization") token: String, - @Body subscribe: com.github.libretube.api.obj.Subscribe - ): com.github.libretube.api.obj.Message + @Body subscribe: Subscribe + ): Message @POST("import") suspend fun importSubscriptions( @Query("override") override: Boolean, @Header("Authorization") token: String, @Body channels: List - ): com.github.libretube.api.obj.Message + ): Message @POST("import/playlist") suspend fun importPlaylist( @Header("Authorization") token: String, - @Body playlistId: com.github.libretube.api.obj.PlaylistId - ): com.github.libretube.api.obj.Message + @Body playlistId: PlaylistId + ): PlaylistId @GET("user/playlists") - suspend fun playlists(@Header("Authorization") token: String): List + suspend fun getUserPlaylists(@Header("Authorization") token: String): List @POST("user/playlists/rename") suspend fun renamePlaylist( @Header("Authorization") token: String, - @Body playlistId: com.github.libretube.api.obj.PlaylistId + @Body playlistId: PlaylistId ) @POST("user/playlists/delete") suspend fun deletePlaylist( @Header("Authorization") token: String, - @Body playlistId: com.github.libretube.api.obj.PlaylistId - ): com.github.libretube.api.obj.Message + @Body playlistId: PlaylistId + ): Message @POST("user/playlists/create") suspend fun createPlaylist( @Header("Authorization") token: String, - @Body name: com.github.libretube.api.obj.Playlists - ): com.github.libretube.api.obj.PlaylistId + @Body name: Playlists + ): PlaylistId @POST("user/playlists/add") suspend fun addToPlaylist( @Header("Authorization") token: String, - @Body playlistId: com.github.libretube.api.obj.PlaylistId - ): com.github.libretube.api.obj.Message + @Body playlistId: PlaylistId + ): Message @POST("user/playlists/remove") suspend fun removeFromPlaylist( @Header("Authorization") token: String, - @Body playlistId: com.github.libretube.api.obj.PlaylistId - ): com.github.libretube.api.obj.Message + @Body playlistId: PlaylistId + ): Message } diff --git a/app/src/main/java/com/github/libretube/api/PlaylistsHelper.kt b/app/src/main/java/com/github/libretube/api/PlaylistsHelper.kt new file mode 100644 index 000000000..8b8beff9a --- /dev/null +++ b/app/src/main/java/com/github/libretube/api/PlaylistsHelper.kt @@ -0,0 +1,187 @@ +package com.github.libretube.api + +import android.content.Context +import android.util.Log +import com.github.libretube.R +import com.github.libretube.api.obj.Playlist +import com.github.libretube.api.obj.PlaylistId +import com.github.libretube.api.obj.Playlists +import com.github.libretube.db.DatabaseHolder +import com.github.libretube.db.obj.LocalPlaylist +import com.github.libretube.enums.PlaylistType +import com.github.libretube.extensions.TAG +import com.github.libretube.extensions.awaitQuery +import com.github.libretube.extensions.toLocalPlaylistItem +import com.github.libretube.extensions.toStreamItem +import com.github.libretube.extensions.toastFromMainThread +import com.github.libretube.util.PreferenceHelper +import com.github.libretube.util.ProxyHelper +import retrofit2.HttpException +import java.io.IOException + +object PlaylistsHelper { + private val pipedPlaylistRegex = "[\\da-fA-F]{8}-[\\da-fA-F]{4}-[\\da-fA-F]{4}-[\\da-fA-F]{4}-[\\da-fA-F]{12}".toRegex() + + val token get() = PreferenceHelper.getToken() + + private fun loggedIn() = token != "" + + suspend fun getPlaylists(): List { + if (loggedIn()) return RetrofitInstance.authApi.getUserPlaylists(token) + + val localPlaylists = awaitQuery { + DatabaseHolder.Database.localPlaylistsDao().getAll() + } + val playlists = mutableListOf() + localPlaylists.forEach { + playlists.add( + Playlists( + id = it.playlist.id.toString(), + name = it.playlist.name, + thumbnail = ProxyHelper.rewriteUrl(it.playlist.thumbnailUrl), + videos = it.videos.size.toLong() + ) + ) + } + return playlists + } + + suspend fun getPlaylist(playlistType: PlaylistType, playlistId: String): Playlist { + // load locally stored playlists with the auth api + return when (playlistType) { + PlaylistType.PRIVATE -> RetrofitInstance.authApi.getPlaylist(playlistId) + PlaylistType.PUBLIC -> RetrofitInstance.api.getPlaylist(playlistId) + PlaylistType.LOCAL -> { + val relation = awaitQuery { + DatabaseHolder.Database.localPlaylistsDao().getAll() + }.first { it.playlist.id.toString() == playlistId } + return Playlist( + name = relation.playlist.name, + thumbnailUrl = ProxyHelper.rewriteUrl(relation.playlist.thumbnailUrl), + videos = relation.videos.size, + relatedStreams = relation.videos.map { it.toStreamItem() } + ) + } + } + } + + suspend fun createPlaylist(playlistName: String, appContext: Context, onSuccess: () -> Unit) { + if (!loggedIn()) { + awaitQuery { + DatabaseHolder.Database.localPlaylistsDao().createPlaylist( + LocalPlaylist( + name = playlistName, + thumbnailUrl = "" + ) + ) + } + onSuccess.invoke() + return + } + val response = try { + RetrofitInstance.authApi.createPlaylist( + token, + Playlists(name = playlistName) + ) + } catch (e: IOException) { + appContext.toastFromMainThread(R.string.unknown_error) + return + } catch (e: HttpException) { + Log.e(TAG(), e.toString()) + appContext.toastFromMainThread(R.string.server_error) + return + } + if (response.playlistId != null) { + appContext.toastFromMainThread(R.string.playlistCreated) + onSuccess.invoke() + } else { + appContext.toastFromMainThread(R.string.unknown_error) + } + } + + suspend fun addToPlaylist(playlistId: String, videoId: String): Boolean { + if (!loggedIn()) { + val localPlaylistItem = RetrofitInstance.api.getStreams(videoId).toLocalPlaylistItem(playlistId, videoId) + awaitQuery { + // avoid duplicated videos in a playlist + DatabaseHolder.Database.localPlaylistsDao().deletePlaylistItemsByVideoId(playlistId, videoId) + + // add the new video to the database + DatabaseHolder.Database.localPlaylistsDao().addPlaylistVideo(localPlaylistItem) + val localPlaylist = DatabaseHolder.Database.localPlaylistsDao().getAll() + .first { it.playlist.id.toString() == playlistId } + + if (localPlaylist.playlist.thumbnailUrl == "") { + // set the new playlist thumbnail URL + localPlaylistItem.thumbnailUrl?.let { + localPlaylist.playlist.thumbnailUrl = it + DatabaseHolder.Database.localPlaylistsDao().updatePlaylist(localPlaylist.playlist) + } + } + } + return true + } + + return RetrofitInstance.authApi.addToPlaylist( + token, + PlaylistId(playlistId, videoId) + ).message == "ok" + } + + suspend fun renamePlaylist(playlistId: String, newName: String) { + if (!loggedIn()) { + val playlist = awaitQuery { + DatabaseHolder.Database.localPlaylistsDao().getAll() + }.first { it.playlist.id.toString() == playlistId }.playlist + playlist.name = newName + awaitQuery { + DatabaseHolder.Database.localPlaylistsDao().updatePlaylist(playlist) + } + return + } + + RetrofitInstance.authApi.renamePlaylist( + token, + PlaylistId( + playlistId = playlistId, + newName = newName + ) + ) + } + + suspend fun removeFromPlaylist(playlistId: String, index: Int) { + if (!loggedIn()) { + val transaction = awaitQuery { + DatabaseHolder.Database.localPlaylistsDao().getAll() + }.first { it.playlist.id.toString() == playlistId } + awaitQuery { + DatabaseHolder.Database.localPlaylistsDao().removePlaylistVideo(transaction.videos[index]) + } + if (transaction.videos.size > 1) return + // remove thumbnail if playlist now empty + awaitQuery { + transaction.playlist.thumbnailUrl = "" + DatabaseHolder.Database.localPlaylistsDao().updatePlaylist(transaction.playlist) + } + return + } + + RetrofitInstance.authApi.removeFromPlaylist( + PreferenceHelper.getToken(), + PlaylistId( + playlistId = playlistId, + index = index + ) + ) + } + + fun getPrivateType(): PlaylistType { + return if (loggedIn()) PlaylistType.PRIVATE else PlaylistType.LOCAL + } + + fun getPrivateType(playlistId: String): PlaylistType { + if (playlistId.all { it.isDigit() }) return PlaylistType.LOCAL + if (playlistId.matches(pipedPlaylistRegex)) return PlaylistType.PRIVATE + return PlaylistType.PUBLIC + } +} diff --git a/app/src/main/java/com/github/libretube/api/SubscriptionHelper.kt b/app/src/main/java/com/github/libretube/api/SubscriptionHelper.kt index 86c241d7f..a67dc3a2c 100644 --- a/app/src/main/java/com/github/libretube/api/SubscriptionHelper.kt +++ b/app/src/main/java/com/github/libretube/api/SubscriptionHelper.kt @@ -1,12 +1,18 @@ package com.github.libretube.api +import android.content.Context import android.util.Log +import com.github.libretube.R +import com.github.libretube.api.obj.StreamItem +import com.github.libretube.api.obj.Subscription +import com.github.libretube.constants.PreferenceKeys import com.github.libretube.db.DatabaseHolder.Companion.Database import com.github.libretube.db.obj.LocalSubscription import com.github.libretube.extensions.TAG import com.github.libretube.extensions.awaitQuery import com.github.libretube.extensions.query import com.github.libretube.util.PreferenceHelper +import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -55,6 +61,24 @@ object SubscriptionHelper { } } + fun handleUnsubscribe(context: Context, channelId: String, channelName: String?, onUnsubscribe: () -> Unit) { + if (!PreferenceHelper.getBoolean(PreferenceKeys.CONFIRM_UNSUBSCRIBE, false)) { + unsubscribe(channelId) + onUnsubscribe.invoke() + return + } + + MaterialAlertDialogBuilder(context) + .setTitle(R.string.unsubscribe) + .setMessage(context.getString(R.string.confirm_unsubscribe, channelName)) + .setPositiveButton(R.string.unsubscribe) { _, _ -> + unsubscribe(channelId) + onUnsubscribe.invoke() + } + .setNegativeButton(R.string.cancel, null) + .show() + } + suspend fun isSubscribed(channelId: String): Boolean? { if (PreferenceHelper.getToken() != "") { val isSubscribed = try { @@ -99,7 +123,7 @@ object SubscriptionHelper { } } - fun getLocalSubscriptions(): List { + private fun getLocalSubscriptions(): List { return awaitQuery { Database.localSubscriptionDao().getAll() } @@ -107,6 +131,30 @@ object SubscriptionHelper { fun getFormattedLocalSubscriptions(): String { val localSubscriptions = getLocalSubscriptions() - return localSubscriptions.map { it.channelId }.joinToString(",") + return localSubscriptions.joinToString(",") { it.channelId } + } + + suspend fun getSubscriptions(): List { + return if (PreferenceHelper.getToken() != "") { + RetrofitInstance.authApi.subscriptions( + PreferenceHelper.getToken() + ) + } else { + RetrofitInstance.authApi.unauthenticatedSubscriptions( + getFormattedLocalSubscriptions() + ) + } + } + + suspend fun getFeed(): List { + return if (PreferenceHelper.getToken() != "") { + RetrofitInstance.authApi.getFeed( + PreferenceHelper.getToken() + ) + } else { + RetrofitInstance.authApi.getUnauthenticatedFeed( + getFormattedLocalSubscriptions() + ) + } } } diff --git a/app/src/main/java/com/github/libretube/api/obj/Channel.kt b/app/src/main/java/com/github/libretube/api/obj/Channel.kt index d255187dd..e1b261df4 100644 --- a/app/src/main/java/com/github/libretube/api/obj/Channel.kt +++ b/app/src/main/java/com/github/libretube/api/obj/Channel.kt @@ -12,5 +12,6 @@ data class Channel( var nextpage: String? = null, var subscriberCount: Long = 0, var verified: Boolean = false, - var relatedStreams: List? = null + var relatedStreams: List? = listOf(), + var tabs: List? = listOf() ) diff --git a/app/src/main/java/com/github/libretube/api/obj/Segments.kt b/app/src/main/java/com/github/libretube/api/obj/ChannelTab.kt similarity index 64% rename from app/src/main/java/com/github/libretube/api/obj/Segments.kt rename to app/src/main/java/com/github/libretube/api/obj/ChannelTab.kt index 704bd899c..c7e2d63de 100644 --- a/app/src/main/java/com/github/libretube/api/obj/Segments.kt +++ b/app/src/main/java/com/github/libretube/api/obj/ChannelTab.kt @@ -3,6 +3,7 @@ package com.github.libretube.api.obj import com.fasterxml.jackson.annotation.JsonIgnoreProperties @JsonIgnoreProperties(ignoreUnknown = true) -data class Segments( - val segments: MutableList = arrayListOf() +data class ChannelTab( + val name: String? = null, + val data: String? = null ) diff --git a/app/src/main/java/com/github/libretube/api/obj/ChannelTabResponse.kt b/app/src/main/java/com/github/libretube/api/obj/ChannelTabResponse.kt new file mode 100644 index 000000000..6c6fd7bae --- /dev/null +++ b/app/src/main/java/com/github/libretube/api/obj/ChannelTabResponse.kt @@ -0,0 +1,6 @@ +package com.github.libretube.api.obj + +data class ChannelTabResponse( + val content: List = listOf(), + val nextpage: String? = null +) diff --git a/app/src/main/java/com/github/libretube/api/obj/CommentsPage.kt b/app/src/main/java/com/github/libretube/api/obj/CommentsPage.kt index 469c64a0c..cc0b4ad66 100644 --- a/app/src/main/java/com/github/libretube/api/obj/CommentsPage.kt +++ b/app/src/main/java/com/github/libretube/api/obj/CommentsPage.kt @@ -6,5 +6,5 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties data class CommentsPage( val comments: MutableList = arrayListOf(), val disabled: Boolean? = null, - val nextpage: String? = "" + val nextpage: String? = null ) diff --git a/app/src/main/java/com/github/libretube/api/obj/SearchItem.kt b/app/src/main/java/com/github/libretube/api/obj/ContentItem.kt similarity index 97% rename from app/src/main/java/com/github/libretube/api/obj/SearchItem.kt rename to app/src/main/java/com/github/libretube/api/obj/ContentItem.kt index f55604d8b..af7181e5c 100644 --- a/app/src/main/java/com/github/libretube/api/obj/SearchItem.kt +++ b/app/src/main/java/com/github/libretube/api/obj/ContentItem.kt @@ -3,7 +3,7 @@ package com.github.libretube.api.obj import com.fasterxml.jackson.annotation.JsonIgnoreProperties @JsonIgnoreProperties(ignoreUnknown = true) -data class SearchItem( +data class ContentItem( var url: String? = null, var thumbnail: String? = null, var uploaderName: String? = null, diff --git a/app/src/main/java/com/github/libretube/api/obj/PipedConfig.kt b/app/src/main/java/com/github/libretube/api/obj/PipedConfig.kt new file mode 100644 index 000000000..1595b674f --- /dev/null +++ b/app/src/main/java/com/github/libretube/api/obj/PipedConfig.kt @@ -0,0 +1,7 @@ +package com.github.libretube.api.obj + +data class PipedConfig( + val donationUrl: String? = null, + val statusPageUrl: String? = null, + val imageProxyUrl: String? = null +) diff --git a/app/src/main/java/com/github/libretube/api/obj/PipedStream.kt b/app/src/main/java/com/github/libretube/api/obj/PipedStream.kt index ed9e9d973..58de468e8 100644 --- a/app/src/main/java/com/github/libretube/api/obj/PipedStream.kt +++ b/app/src/main/java/com/github/libretube/api/obj/PipedStream.kt @@ -17,5 +17,7 @@ data class PipedStream( var indexEnd: Int? = null, var width: Int? = null, var height: Int? = null, - var fps: Int? = null + var fps: Int? = null, + val audioTrackName: String? = null, + val audioTrackId: String? = null ) diff --git a/app/src/main/java/com/github/libretube/api/obj/Playlists.kt b/app/src/main/java/com/github/libretube/api/obj/Playlists.kt index 2d46ced8f..9bee35d0e 100644 --- a/app/src/main/java/com/github/libretube/api/obj/Playlists.kt +++ b/app/src/main/java/com/github/libretube/api/obj/Playlists.kt @@ -7,5 +7,6 @@ data class Playlists( var id: String? = null, var name: String? = null, var shortDescription: String? = null, - var thumbnail: String? = null + var thumbnail: String? = null, + var videos: Long? = null ) diff --git a/app/src/main/java/com/github/libretube/api/obj/SearchResult.kt b/app/src/main/java/com/github/libretube/api/obj/SearchResult.kt index 42ff66005..f923a6c7f 100644 --- a/app/src/main/java/com/github/libretube/api/obj/SearchResult.kt +++ b/app/src/main/java/com/github/libretube/api/obj/SearchResult.kt @@ -4,8 +4,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties @JsonIgnoreProperties(ignoreUnknown = true) data class SearchResult( - val items: MutableList? = arrayListOf(), - val nextpage: String? = "", + val items: MutableList? = arrayListOf(), + val nextpage: String? = null, val suggestion: String? = "", val corrected: Boolean? = null ) diff --git a/app/src/main/java/com/github/libretube/api/obj/Segment.kt b/app/src/main/java/com/github/libretube/api/obj/Segment.kt index ea92df057..951a7b472 100644 --- a/app/src/main/java/com/github/libretube/api/obj/Segment.kt +++ b/app/src/main/java/com/github/libretube/api/obj/Segment.kt @@ -4,7 +4,13 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties @JsonIgnoreProperties(ignoreUnknown = true) data class Segment( + val UUID: String? = null, val actionType: String? = null, val category: String? = null, - val segment: List? = arrayListOf() + val description: String? = null, + val locked: Int? = null, + val segment: List = listOf(), + val userID: String? = null, + val videoDuration: Double? = null, + val votes: Int? = null ) diff --git a/app/src/main/java/com/github/libretube/api/obj/SegmentData.kt b/app/src/main/java/com/github/libretube/api/obj/SegmentData.kt new file mode 100644 index 000000000..a383cd801 --- /dev/null +++ b/app/src/main/java/com/github/libretube/api/obj/SegmentData.kt @@ -0,0 +1,10 @@ +package com.github.libretube.api.obj + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +data class SegmentData( + val hash: String? = null, + val segments: List = listOf(), + val videoID: String? = null +) diff --git a/app/src/main/java/com/github/libretube/constants/Constants.kt b/app/src/main/java/com/github/libretube/constants/Constants.kt index b3a959e13..4019f5d69 100644 --- a/app/src/main/java/com/github/libretube/constants/Constants.kt +++ b/app/src/main/java/com/github/libretube/constants/Constants.kt @@ -9,7 +9,6 @@ const val GITHUB_API_URL = "https://api.github.com/repos/libre-tube/LibreTube/re * Links for the about fragment */ const val WEBSITE_URL = "https://libre-tube.github.io/" -const val DONATE_URL = "https://github.com/libre-tube/LibreTube#donate" const val GITHUB_URL = "https://github.com/libre-tube/LibreTube" const val PIPED_GITHUB_URL = "https://github.com/TeamPiped/Piped" const val WEBLATE_URL = "https://hosted.weblate.org/projects/libretube/libretube/" @@ -27,7 +26,7 @@ const val TWITTER_URL = "https://twitter.com/libretube" /** * Share Dialog */ -const val PIPED_FRONTEND_URL = "https://piped.kavin.rocks" +const val PIPED_FRONTEND_URL = "https://piped.video" const val YOUTUBE_FRONTEND_URL = "https://www.youtube.com" /** diff --git a/app/src/main/java/com/github/libretube/constants/DownloadType.kt b/app/src/main/java/com/github/libretube/constants/DownloadType.kt deleted file mode 100644 index 702c140b3..000000000 --- a/app/src/main/java/com/github/libretube/constants/DownloadType.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.github.libretube.constants - -/** - * object for saving the download type - */ -object DownloadType { - const val AUDIO = 0 - const val VIDEO = 1 - const val AUDIO_VIDEO = 2 - const val NONE = 3 -} diff --git a/app/src/main/java/com/github/libretube/constants/IntentData.kt b/app/src/main/java/com/github/libretube/constants/IntentData.kt index 82bef9e0b..b1f1d6a26 100644 --- a/app/src/main/java/com/github/libretube/constants/IntentData.kt +++ b/app/src/main/java/com/github/libretube/constants/IntentData.kt @@ -8,4 +8,6 @@ object IntentData { const val timeStamp = "timeStamp" const val position = "position" const val fileName = "fileName" + const val openQueueOnce = "openQueue" + const val playlistType = "playlistType" } diff --git a/app/src/main/java/com/github/libretube/constants/PreferenceKeys.kt b/app/src/main/java/com/github/libretube/constants/PreferenceKeys.kt index 1fdd5e0ce..e87a00604 100644 --- a/app/src/main/java/com/github/libretube/constants/PreferenceKeys.kt +++ b/app/src/main/java/com/github/libretube/constants/PreferenceKeys.kt @@ -10,6 +10,7 @@ object PreferenceKeys { const val AUTH_PREF_FILE = "auth" const val TOKEN = "token" const val USERNAME = "username" + const val IMAGE_PROXY_URL = "image_proxy_url" /** * General @@ -20,7 +21,7 @@ object PreferenceKeys { const val BREAK_REMINDER_TOGGLE = "break_reminder_toggle" const val BREAK_REMINDER = "break_reminder" const val SAVE_FEED = "save_feed" - const val NAVBAR_ITEMS = "nav_bar_items" + const val NAVBAR_ITEMS = "navbar_items" /** * Appearance @@ -33,7 +34,7 @@ object PreferenceKeys { const val APP_ICON = "icon_change" const val LEGACY_SUBSCRIPTIONS = "legacy_subscriptions" const val LEGACY_SUBSCRIPTIONS_COLUMNS = "legacy_subscriptions_columns" - const val ALTERNATIVE_TRENDING_LAYOUT = "trending_layout" + const val ALTERNATIVE_VIDEOS_LAYOUT = "alternative_videos_layout" const val NEW_VIDEOS_BADGE = "new_videos_badge" const val PLAYLISTS_ORDER = "playlists_order" @@ -77,13 +78,16 @@ object PreferenceKeys { const val PICTURE_IN_PICTURE = "picture_in_picture" const val PLAYER_RESIZE_MODE = "player_resize_mode" const val SB_SKIP_MANUALLY = "sb_skip_manually_key" - const val LIMIT_HLS = "limit_hls" - const val PROGRESSIVE_LOADING_INTERVAL_SIZE = "progressive_loading_interval" + const val SB_SHOW_MARKERS = "sb_show_markers" + const val ALTERNATIVE_PLAYER_LAYOUT = "alternative_player_layout" + const val USE_HLS_OVER_DASH = "use_hls" + const val QUEUE_AUTO_INSERT_RELATED = "queue_insert_related_videos" /** * Background mode */ const val BACKGROUND_PLAYBACK_SPEED = "background_playback_speed" + const val NOTIFICATION_OPEN_QUEUE = "notification_open_queue" /** * Notifications @@ -92,6 +96,10 @@ object PreferenceKeys { const val CHECKING_FREQUENCY = "checking_frequency" const val REQUIRED_NETWORK = "required_network" const val LAST_STREAM_VIDEO_ID = "last_stream_video_id" + const val IGNORED_NOTIFICATION_CHANNELS = "ignored_notification_channels" + const val NOTIFICATION_TIME_ENABLED = "notification_time" + const val NOTIFICATION_START_TIME = "notification_start_time" + const val NOTIFICATION_END_TIME = "notification_end_time" /** * Advanced @@ -103,6 +111,8 @@ object PreferenceKeys { const val CLEAR_WATCH_HISTORY = "clear_watch_history" const val CLEAR_WATCH_POSITIONS = "clear_watch_positions" const val SHARE_WITH_TIME_CODE = "share_with_time_code" + const val CONFIRM_UNSUBSCRIBE = "confirm_unsubscribing" + const val CLEAR_BOOKMARKS = "clear_bookmarks" /** * History diff --git a/app/src/main/java/com/github/libretube/constants/ShareObjectType.kt b/app/src/main/java/com/github/libretube/constants/ShareObjectType.kt deleted file mode 100644 index 175585f65..000000000 --- a/app/src/main/java/com/github/libretube/constants/ShareObjectType.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.github.libretube.constants - -object ShareObjectType { - const val VIDEO = 0 - const val PLAYLIST = 1 - const val CHANNEL = 2 -} diff --git a/app/src/main/java/com/github/libretube/db/AppDatabase.kt b/app/src/main/java/com/github/libretube/db/AppDatabase.kt index 88daecd16..9342e51b4 100644 --- a/app/src/main/java/com/github/libretube/db/AppDatabase.kt +++ b/app/src/main/java/com/github/libretube/db/AppDatabase.kt @@ -1,14 +1,20 @@ package com.github.libretube.db +import androidx.room.AutoMigration import androidx.room.Database import androidx.room.RoomDatabase import com.github.libretube.db.dao.CustomInstanceDao +import com.github.libretube.db.dao.LocalPlaylistsDao import com.github.libretube.db.dao.LocalSubscriptionDao +import com.github.libretube.db.dao.PlaylistBookmarkDao import com.github.libretube.db.dao.SearchHistoryDao import com.github.libretube.db.dao.WatchHistoryDao import com.github.libretube.db.dao.WatchPositionDao import com.github.libretube.db.obj.CustomInstance +import com.github.libretube.db.obj.LocalPlaylist +import com.github.libretube.db.obj.LocalPlaylistItem import com.github.libretube.db.obj.LocalSubscription +import com.github.libretube.db.obj.PlaylistBookmark import com.github.libretube.db.obj.SearchHistoryItem import com.github.libretube.db.obj.WatchHistoryItem import com.github.libretube.db.obj.WatchPosition @@ -19,9 +25,16 @@ import com.github.libretube.db.obj.WatchPosition WatchPosition::class, SearchHistoryItem::class, CustomInstance::class, - LocalSubscription::class + LocalSubscription::class, + PlaylistBookmark::class, + LocalPlaylist::class, + LocalPlaylistItem::class ], - version = 7 + version = 9, + autoMigrations = [ + AutoMigration(from = 7, to = 8), + AutoMigration(from = 8, to = 9) + ] ) abstract class AppDatabase : RoomDatabase() { /** @@ -48,4 +61,14 @@ abstract class AppDatabase : RoomDatabase() { * Local Subscriptions */ abstract fun localSubscriptionDao(): LocalSubscriptionDao + + /** + * Bookmarked Playlists + */ + abstract fun playlistBookmarkDao(): PlaylistBookmarkDao + + /** + * Local playlists + */ + abstract fun localPlaylistsDao(): LocalPlaylistsDao } diff --git a/app/src/main/java/com/github/libretube/db/DatabaseHelper.kt b/app/src/main/java/com/github/libretube/db/DatabaseHelper.kt index c5c2a21c8..c6fdf259f 100644 --- a/app/src/main/java/com/github/libretube/db/DatabaseHelper.kt +++ b/app/src/main/java/com/github/libretube/db/DatabaseHelper.kt @@ -5,12 +5,13 @@ import com.github.libretube.constants.PreferenceKeys import com.github.libretube.db.DatabaseHolder.Companion.Database import com.github.libretube.db.obj.SearchHistoryItem import com.github.libretube.db.obj.WatchHistoryItem -import com.github.libretube.db.obj.WatchPosition import com.github.libretube.extensions.query import com.github.libretube.extensions.toID import com.github.libretube.util.PreferenceHelper object DatabaseHelper { + private const val MAX_SEARCH_HISTORY_SIZE = 20 + fun addToWatchHistory(videoId: String, streams: Streams) { val watchHistoryItem = WatchHistoryItem( videoId, @@ -37,40 +38,13 @@ object DatabaseHelper { } } - fun removeFromWatchHistory(index: Int) { - query { - Database.watchHistoryDao().delete( - Database.watchHistoryDao().getAll()[index] - ) - } - } - - fun saveWatchPosition(videoId: String, position: Long) { - val watchPosition = WatchPosition( - videoId, - position - ) - query { - Database.watchPositionDao().insertAll(watchPosition) - } - } - - fun removeWatchPosition(videoId: String) { - query { - Database.watchPositionDao().findById(videoId)?.let { - Database.watchPositionDao().delete(it) - } - } - } - fun addToSearchHistory(searchHistoryItem: SearchHistoryItem) { query { Database.searchHistoryDao().insertAll(searchHistoryItem) - val maxHistorySize = 20 // delete the first watch history entry if the limit is reached val searchHistory = Database.searchHistoryDao().getAll() - if (searchHistory.size > maxHistorySize) { + if (searchHistory.size > MAX_SEARCH_HISTORY_SIZE) { Database.searchHistoryDao() .delete(searchHistory.first()) } diff --git a/app/src/main/java/com/github/libretube/db/dao/LocalPlaylistsDao.kt b/app/src/main/java/com/github/libretube/db/dao/LocalPlaylistsDao.kt new file mode 100644 index 000000000..f0eb47950 --- /dev/null +++ b/app/src/main/java/com/github/libretube/db/dao/LocalPlaylistsDao.kt @@ -0,0 +1,42 @@ +package com.github.libretube.db.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import com.github.libretube.db.obj.LocalPlaylist +import com.github.libretube.db.obj.LocalPlaylistItem +import com.github.libretube.db.obj.LocalPlaylistWithVideos + +@Dao +interface LocalPlaylistsDao { + @Transaction + @Query("SELECT * FROM LocalPlaylist") + fun getAll(): List + + @Insert + fun createPlaylist(playlist: LocalPlaylist) + + @Update + fun updatePlaylist(playlist: LocalPlaylist) + + @Delete + fun deletePlaylist(playlist: LocalPlaylist) + + @Query("DELETE FROM localPlaylist WHERE id = :playlistId") + fun deletePlaylistById(playlistId: String) + + @Insert + fun addPlaylistVideo(playlistVideo: LocalPlaylistItem) + + @Delete + fun removePlaylistVideo(playlistVideo: LocalPlaylistItem) + + @Query("DELETE FROM localPlaylistItem WHERE playlistId = :playlistId") + fun deletePlaylistItemsByPlaylistId(playlistId: String) + + @Query("DELETE FROM localPlaylistItem WHERE playlistId = :playlistId AND videoId = :videoId") + fun deletePlaylistItemsByVideoId(playlistId: String, videoId: String) +} diff --git a/app/src/main/java/com/github/libretube/db/dao/PlaylistBookmarkDao.kt b/app/src/main/java/com/github/libretube/db/dao/PlaylistBookmarkDao.kt new file mode 100644 index 000000000..493c96470 --- /dev/null +++ b/app/src/main/java/com/github/libretube/db/dao/PlaylistBookmarkDao.kt @@ -0,0 +1,32 @@ +package com.github.libretube.db.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.github.libretube.db.obj.PlaylistBookmark + +@Dao +interface PlaylistBookmarkDao { + @Query("SELECT * FROM playlistBookmark") + fun getAll(): List + + @Query("SELECT * FROM playlistBookmark WHERE playlistId LIKE :playlistId LIMIT 1") + fun findById(playlistId: String): PlaylistBookmark + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertAll(vararg bookmarks: PlaylistBookmark) + + @Delete + fun delete(playlistBookmark: PlaylistBookmark) + + @Query("DELETE FROM playlistBookmark WHERE playlistId = :playlistId") + fun deleteById(playlistId: String) + + @Query("SELECT EXISTS(SELECT * FROM playlistBookmark WHERE playlistId= :playlistId)") + fun includes(playlistId: String): Boolean + + @Query("DELETE FROM playlistBookmark") + fun deleteAll() +} diff --git a/app/src/main/java/com/github/libretube/db/dao/WatchPositionDao.kt b/app/src/main/java/com/github/libretube/db/dao/WatchPositionDao.kt index 6855eaab4..b08f6e2cf 100644 --- a/app/src/main/java/com/github/libretube/db/dao/WatchPositionDao.kt +++ b/app/src/main/java/com/github/libretube/db/dao/WatchPositionDao.kt @@ -21,6 +21,9 @@ interface WatchPositionDao { @Delete fun delete(watchPosition: WatchPosition) + @Query("DELETE FROM watchHistoryItem WHERE videoId = :videoId") + fun deleteById(videoId: String) + @Query("DELETE FROM watchPosition") fun deleteAll() } diff --git a/app/src/main/java/com/github/libretube/db/obj/LocalPlaylist.kt b/app/src/main/java/com/github/libretube/db/obj/LocalPlaylist.kt new file mode 100644 index 000000000..2364af951 --- /dev/null +++ b/app/src/main/java/com/github/libretube/db/obj/LocalPlaylist.kt @@ -0,0 +1,12 @@ +package com.github.libretube.db.obj + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +data class LocalPlaylist( + @PrimaryKey(autoGenerate = true) + val id: Int = 0, + var name: String, + var thumbnailUrl: String +) diff --git a/app/src/main/java/com/github/libretube/db/obj/LocalPlaylistItem.kt b/app/src/main/java/com/github/libretube/db/obj/LocalPlaylistItem.kt new file mode 100644 index 000000000..65d9dc134 --- /dev/null +++ b/app/src/main/java/com/github/libretube/db/obj/LocalPlaylistItem.kt @@ -0,0 +1,19 @@ +package com.github.libretube.db.obj + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +data class LocalPlaylistItem( + @PrimaryKey(autoGenerate = true) val id: Int = 0, + @ColumnInfo var playlistId: Int, + @ColumnInfo val videoId: String, + @ColumnInfo val title: String? = null, + @ColumnInfo val uploadDate: String? = null, + @ColumnInfo val uploader: String? = null, + @ColumnInfo val uploaderUrl: String? = null, + @ColumnInfo val uploaderAvatar: String? = null, + @ColumnInfo val thumbnailUrl: String? = null, + @ColumnInfo val duration: Long? = null +) diff --git a/app/src/main/java/com/github/libretube/db/obj/LocalPlaylistWithVideos.kt b/app/src/main/java/com/github/libretube/db/obj/LocalPlaylistWithVideos.kt new file mode 100644 index 000000000..a4cca23cd --- /dev/null +++ b/app/src/main/java/com/github/libretube/db/obj/LocalPlaylistWithVideos.kt @@ -0,0 +1,13 @@ +package com.github.libretube.db.obj + +import androidx.room.Embedded +import androidx.room.Relation + +data class LocalPlaylistWithVideos( + @Embedded val playlist: LocalPlaylist, + @Relation( + parentColumn = "id", + entityColumn = "playlistId" + ) + val videos: List +) diff --git a/app/src/main/java/com/github/libretube/db/obj/PlaylistBookmark.kt b/app/src/main/java/com/github/libretube/db/obj/PlaylistBookmark.kt new file mode 100644 index 000000000..fa7280580 --- /dev/null +++ b/app/src/main/java/com/github/libretube/db/obj/PlaylistBookmark.kt @@ -0,0 +1,16 @@ + +package com.github.libretube.db.obj + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "playlistBookmark") +data class PlaylistBookmark( + @PrimaryKey + val playlistId: String = "", + val playlistName: String? = null, + var thumbnailUrl: String? = null, + var uploader: String? = null, + var uploaderUrl: String? = null, + var uploaderAvatar: String? = null +) diff --git a/app/src/main/java/com/github/libretube/db/obj/WatchHistoryItem.kt b/app/src/main/java/com/github/libretube/db/obj/WatchHistoryItem.kt index c5fed1b1b..45da337df 100644 --- a/app/src/main/java/com/github/libretube/db/obj/WatchHistoryItem.kt +++ b/app/src/main/java/com/github/libretube/db/obj/WatchHistoryItem.kt @@ -11,7 +11,7 @@ data class WatchHistoryItem( @ColumnInfo val uploadDate: String? = null, @ColumnInfo val uploader: String? = null, @ColumnInfo val uploaderUrl: String? = null, - @ColumnInfo val uploaderAvatar: String? = null, - @ColumnInfo val thumbnailUrl: String? = null, + @ColumnInfo var uploaderAvatar: String? = null, + @ColumnInfo var thumbnailUrl: String? = null, @ColumnInfo val duration: Long? = null ) diff --git a/app/src/main/java/com/github/libretube/enums/DownloadType.kt b/app/src/main/java/com/github/libretube/enums/DownloadType.kt new file mode 100644 index 000000000..0df017b47 --- /dev/null +++ b/app/src/main/java/com/github/libretube/enums/DownloadType.kt @@ -0,0 +1,11 @@ +package com.github.libretube.enums + +/** + * object for saving the download type + */ +enum class DownloadType { + AUDIO, + VIDEO, + AUDIO_VIDEO, + NONE +} diff --git a/app/src/main/java/com/github/libretube/enums/PlaylistType.kt b/app/src/main/java/com/github/libretube/enums/PlaylistType.kt new file mode 100644 index 000000000..852a1cda9 --- /dev/null +++ b/app/src/main/java/com/github/libretube/enums/PlaylistType.kt @@ -0,0 +1,18 @@ +package com.github.libretube.enums + +enum class PlaylistType { + /** + * Local playlist + */ + LOCAL, + + /** + * Piped playlist + */ + PRIVATE, + + /** + * YouTube playlist + */ + PUBLIC +} diff --git a/app/src/main/java/com/github/libretube/enums/ShareObjectType.kt b/app/src/main/java/com/github/libretube/enums/ShareObjectType.kt new file mode 100644 index 000000000..09b55be67 --- /dev/null +++ b/app/src/main/java/com/github/libretube/enums/ShareObjectType.kt @@ -0,0 +1,7 @@ +package com.github.libretube.enums + +enum class ShareObjectType { + VIDEO, + PLAYLIST, + CHANNEL +} diff --git a/app/src/main/java/com/github/libretube/extensions/CreateDir.kt b/app/src/main/java/com/github/libretube/extensions/CreateDir.kt deleted file mode 100644 index 5300ff318..000000000 --- a/app/src/main/java/com/github/libretube/extensions/CreateDir.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.github.libretube.extensions - -import java.io.File - -fun File.createDir() = apply { - if (!this.exists()) this.mkdirs() -} diff --git a/app/src/main/java/com/github/libretube/extensions/FormatShort.kt b/app/src/main/java/com/github/libretube/extensions/FormatShort.kt index f0751db56..bb010765b 100644 --- a/app/src/main/java/com/github/libretube/extensions/FormatShort.kt +++ b/app/src/main/java/com/github/libretube/extensions/FormatShort.kt @@ -3,15 +3,17 @@ package com.github.libretube.extensions import java.math.BigDecimal import java.math.RoundingMode +@Suppress("KotlinConstantConditions") fun Long?.formatShort(): String = when { - this!! < 1000 -> { + this == null -> (0).toString() + this < 1000 -> { this.toString() } - this in 1000..999999 -> { + this in (1000..999999) -> { val decimal = BigDecimal(this / 1000).setScale(0, RoundingMode.HALF_EVEN) decimal.toString() + "K" } - this in 1000000..10000000 -> { + this in (1000000..10000000) -> { val decimal = BigDecimal(this / 1000000).setScale(0, RoundingMode.HALF_EVEN) decimal.toString() + "M" } diff --git a/app/src/main/java/com/github/libretube/extensions/Move.kt b/app/src/main/java/com/github/libretube/extensions/Move.kt new file mode 100644 index 000000000..bd556be87 --- /dev/null +++ b/app/src/main/java/com/github/libretube/extensions/Move.kt @@ -0,0 +1,7 @@ +package com.github.libretube.extensions + +fun MutableList.move(oldPosition: Int, newPosition: Int) { + val item = this.get(oldPosition) + this.removeAt(oldPosition) + this.add(newPosition, item) +} diff --git a/app/src/main/java/com/github/libretube/extensions/Round.kt b/app/src/main/java/com/github/libretube/extensions/Round.kt new file mode 100644 index 000000000..0be63d173 --- /dev/null +++ b/app/src/main/java/com/github/libretube/extensions/Round.kt @@ -0,0 +1,9 @@ +package com.github.libretube.extensions + +import kotlin.math.pow +import kotlin.math.roundToInt + +fun Float.round(decimalPlaces: Int): Float { + return (this * 10.0.pow(decimalPlaces.toDouble())).roundToInt() / 10.0.pow(decimalPlaces.toDouble()) + .toFloat() +} diff --git a/app/src/main/java/com/github/libretube/extensions/Serializable.kt b/app/src/main/java/com/github/libretube/extensions/Serializable.kt new file mode 100644 index 000000000..25c9cb521 --- /dev/null +++ b/app/src/main/java/com/github/libretube/extensions/Serializable.kt @@ -0,0 +1,13 @@ +package com.github.libretube.ui.extensions + +import android.os.Build +import android.os.Bundle +import java.io.Serializable + +inline fun Bundle.serializable(key: String): T? = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> getSerializable(key, T::class.java) + else -> { + @Suppress("DEPRECATION") + getSerializable(key) as? T + } +} diff --git a/app/src/main/java/com/github/libretube/extensions/TAG.kt b/app/src/main/java/com/github/libretube/extensions/Tag.kt similarity index 100% rename from app/src/main/java/com/github/libretube/extensions/TAG.kt rename to app/src/main/java/com/github/libretube/extensions/Tag.kt diff --git a/app/src/main/java/com/github/libretube/extensions/ToID.kt b/app/src/main/java/com/github/libretube/extensions/ToID.kt index df7b284e0..b3439cb72 100644 --- a/app/src/main/java/com/github/libretube/extensions/ToID.kt +++ b/app/src/main/java/com/github/libretube/extensions/ToID.kt @@ -3,9 +3,8 @@ package com.github.libretube.extensions /** * format a Piped route to an ID */ -fun Any.toID(): String { +fun String.toID(): String { return this - .toString() .replace("/watch?v=", "") // videos .replace("/channel/", "") // channels .replace("/playlist?list=", "") // playlists diff --git a/app/src/main/java/com/github/libretube/extensions/ToLocalPlaylistItem.kt b/app/src/main/java/com/github/libretube/extensions/ToLocalPlaylistItem.kt new file mode 100644 index 000000000..11146b1f6 --- /dev/null +++ b/app/src/main/java/com/github/libretube/extensions/ToLocalPlaylistItem.kt @@ -0,0 +1,18 @@ +package com.github.libretube.extensions + +import com.github.libretube.api.obj.Streams +import com.github.libretube.db.obj.LocalPlaylistItem + +fun Streams.toLocalPlaylistItem(playlistId: String, videoId: String): LocalPlaylistItem { + return LocalPlaylistItem( + playlistId = playlistId.toInt(), + videoId = videoId, + title = title, + thumbnailUrl = thumbnailUrl, + uploader = uploader, + uploaderUrl = uploaderUrl, + uploaderAvatar = uploaderAvatar, + uploadDate = uploadDate, + duration = duration + ) +} diff --git a/app/src/main/java/com/github/libretube/extensions/ToStreamItem.kt b/app/src/main/java/com/github/libretube/extensions/ToStreamItem.kt new file mode 100644 index 000000000..2be7bbde4 --- /dev/null +++ b/app/src/main/java/com/github/libretube/extensions/ToStreamItem.kt @@ -0,0 +1,37 @@ +package com.github.libretube.extensions + +import com.github.libretube.api.obj.StreamItem +import com.github.libretube.api.obj.Streams +import com.github.libretube.db.obj.LocalPlaylistItem +import com.github.libretube.util.ProxyHelper + +fun Streams.toStreamItem(videoId: String): StreamItem { + return StreamItem( + url = videoId, + title = title, + thumbnail = thumbnailUrl, + uploaderName = uploader, + uploaderUrl = uploaderUrl, + uploaderAvatar = uploaderAvatar, + uploadedDate = uploadDate, + uploaded = null, + duration = duration, + views = views, + uploaderVerified = uploaderVerified, + shortDescription = description + ) +} + +fun LocalPlaylistItem.toStreamItem(): StreamItem { + return StreamItem( + url = videoId, + title = title, + thumbnail = ProxyHelper.rewriteUrl(thumbnailUrl), + uploaderName = uploader, + uploaderUrl = uploaderUrl, + uploaderAvatar = ProxyHelper.rewriteUrl(uploaderAvatar), + uploadedDate = uploadDate, + uploaded = null, + duration = duration + ) +} diff --git a/app/src/main/java/com/github/libretube/extensions/ToastFromMainThread.kt b/app/src/main/java/com/github/libretube/extensions/ToastFromMainThread.kt new file mode 100644 index 000000000..2be369899 --- /dev/null +++ b/app/src/main/java/com/github/libretube/extensions/ToastFromMainThread.kt @@ -0,0 +1,20 @@ +package com.github.libretube.extensions + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.widget.Toast + +fun Context.toastFromMainThread(text: String) { + Handler(Looper.getMainLooper()).post { + Toast.makeText( + this, + text, + Toast.LENGTH_SHORT + ).show() + } +} + +fun Context.toastFromMainThread(stringId: Int) { + toastFromMainThread(getString(stringId)) +} diff --git a/app/src/main/java/com/github/libretube/models/interfaces/DoubleTapInterface.kt b/app/src/main/java/com/github/libretube/models/interfaces/DoubleTapInterface.kt deleted file mode 100644 index b26a13b4f..000000000 --- a/app/src/main/java/com/github/libretube/models/interfaces/DoubleTapInterface.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.github.libretube.models.interfaces - -interface DoubleTapInterface { - fun onEvent(x: Float) -} diff --git a/app/src/main/java/com/github/libretube/models/interfaces/PlayerOptionsInterface.kt b/app/src/main/java/com/github/libretube/models/interfaces/PlayerOptionsInterface.kt deleted file mode 100644 index 66d3d87d9..000000000 --- a/app/src/main/java/com/github/libretube/models/interfaces/PlayerOptionsInterface.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.github.libretube.models.interfaces - -interface PlayerOptionsInterface { - fun onCaptionClicked() - - fun onQualityClicked() -} diff --git a/app/src/main/java/com/github/libretube/obj/BackupFile.kt b/app/src/main/java/com/github/libretube/obj/BackupFile.kt index 5d6770956..82e8a75ec 100644 --- a/app/src/main/java/com/github/libretube/obj/BackupFile.kt +++ b/app/src/main/java/com/github/libretube/obj/BackupFile.kt @@ -1,7 +1,9 @@ package com.github.libretube.obj import com.github.libretube.db.obj.CustomInstance +import com.github.libretube.db.obj.LocalPlaylistWithVideos import com.github.libretube.db.obj.LocalSubscription +import com.github.libretube.db.obj.PlaylistBookmark import com.github.libretube.db.obj.SearchHistoryItem import com.github.libretube.db.obj.WatchHistoryItem import com.github.libretube.db.obj.WatchPosition @@ -12,5 +14,7 @@ data class BackupFile( var searchHistory: List? = null, var localSubscriptions: List? = null, var customInstances: List? = null, + var playlistBookmarks: List? = null, + var localPlaylists: List? = null, var preferences: List? = null ) diff --git a/app/src/main/java/com/github/libretube/obj/BottomSheetItem.kt b/app/src/main/java/com/github/libretube/obj/BottomSheetItem.kt index d9614e5f8..73195412a 100644 --- a/app/src/main/java/com/github/libretube/obj/BottomSheetItem.kt +++ b/app/src/main/java/com/github/libretube/obj/BottomSheetItem.kt @@ -3,5 +3,6 @@ package com.github.libretube.obj data class BottomSheetItem( val title: String, val drawable: Int? = null, - val currentValue: String? = null + val getCurrent: () -> String? = { null }, + val onClick: () -> Unit = {} ) diff --git a/app/src/main/java/com/github/libretube/obj/ChannelTabs.kt b/app/src/main/java/com/github/libretube/obj/ChannelTabs.kt new file mode 100644 index 000000000..9a7c91857 --- /dev/null +++ b/app/src/main/java/com/github/libretube/obj/ChannelTabs.kt @@ -0,0 +1,14 @@ +package com.github.libretube.obj + +import androidx.annotation.IdRes +import com.github.libretube.R + +sealed class ChannelTabs( + val identifierName: String, + @IdRes val chipId: Int +) { + object Playlists : ChannelTabs("playlists", R.id.playlists) + object Shorts : ChannelTabs("shorts", R.id.shorts) + object Livestreams : ChannelTabs("livestreams", R.id.livestreams) + object Channels : ChannelTabs("channels", R.id.channels) +} diff --git a/app/src/main/java/com/github/libretube/obj/DownloadedFile.kt b/app/src/main/java/com/github/libretube/obj/DownloadedFile.kt index ffa9c9d55..83450823d 100644 --- a/app/src/main/java/com/github/libretube/obj/DownloadedFile.kt +++ b/app/src/main/java/com/github/libretube/obj/DownloadedFile.kt @@ -6,7 +6,6 @@ import com.github.libretube.api.obj.Streams data class DownloadedFile( val name: String, val size: Long, - val type: Int, var metadata: Streams? = null, var thumbnail: Bitmap? = null ) diff --git a/app/src/main/java/com/github/libretube/obj/NavBarItem.kt b/app/src/main/java/com/github/libretube/obj/NavBarItem.kt deleted file mode 100644 index eac14a1c7..000000000 --- a/app/src/main/java/com/github/libretube/obj/NavBarItem.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.github.libretube.obj - -data class NavBarItem( - val id: Int = 0, - val title: String = "", - var isEnabled: Boolean = true -) diff --git a/app/src/main/java/com/github/libretube/obj/ShareData.kt b/app/src/main/java/com/github/libretube/obj/ShareData.kt new file mode 100644 index 000000000..532558af0 --- /dev/null +++ b/app/src/main/java/com/github/libretube/obj/ShareData.kt @@ -0,0 +1,8 @@ +package com.github.libretube.obj + +data class ShareData( + val currentChannel: String? = null, + val currentVideo: String? = null, + val currentPlaylist: String? = null, + var currentPosition: Long? = null +) diff --git a/app/src/main/java/com/github/libretube/obj/VideoResolution.kt b/app/src/main/java/com/github/libretube/obj/VideoResolution.kt new file mode 100644 index 000000000..b234cfda2 --- /dev/null +++ b/app/src/main/java/com/github/libretube/obj/VideoResolution.kt @@ -0,0 +1,7 @@ +package com.github.libretube.obj + +data class VideoResolution( + val name: String, + val resolution: Int? = null, + val adaptiveSourceUrl: String? = null +) diff --git a/app/src/main/java/com/github/libretube/services/BackgroundMode.kt b/app/src/main/java/com/github/libretube/services/BackgroundMode.kt index 226643432..719d523cd 100644 --- a/app/src/main/java/com/github/libretube/services/BackgroundMode.kt +++ b/app/src/main/java/com/github/libretube/services/BackgroundMode.kt @@ -9,23 +9,24 @@ import android.os.Build import android.os.Handler import android.os.IBinder import android.os.Looper -import android.util.Log import android.widget.Toast import com.fasterxml.jackson.databind.ObjectMapper import com.github.libretube.R import com.github.libretube.api.RetrofitInstance import com.github.libretube.api.obj.Segment -import com.github.libretube.api.obj.Segments +import com.github.libretube.api.obj.SegmentData import com.github.libretube.api.obj.Streams import com.github.libretube.constants.BACKGROUND_CHANNEL_ID import com.github.libretube.constants.IntentData import com.github.libretube.constants.PLAYER_NOTIFICATION_ID import com.github.libretube.constants.PreferenceKeys -import com.github.libretube.db.DatabaseHelper -import com.github.libretube.db.DatabaseHolder +import com.github.libretube.db.DatabaseHolder.Companion.Database +import com.github.libretube.db.obj.WatchPosition +import com.github.libretube.enums.PlaylistType import com.github.libretube.extensions.awaitQuery +import com.github.libretube.extensions.query import com.github.libretube.extensions.toID -import com.github.libretube.util.AutoPlayHelper +import com.github.libretube.extensions.toStreamItem import com.github.libretube.util.NowPlayingNotification import com.github.libretube.util.PlayerHelper import com.github.libretube.util.PlayingQueue @@ -53,6 +54,7 @@ class BackgroundMode : Service() { *PlaylistId for autoplay */ private var playlistId: String? = null + private var playlistType: PlaylistType? = null /** * The response that gets when called the Api. @@ -73,23 +75,13 @@ class BackgroundMode : Service() { /** * SponsorBlock Segment data */ - private var segmentData: Segments? = null + private var segmentData: SegmentData? = null /** * [Notification] for the player */ private lateinit var nowPlayingNotification: NowPlayingNotification - /** - * The [videoId] of the next stream for autoplay - */ - private var nextStreamId: String? = null - - /** - * Helper for finding the next video in the playlist - */ - private lateinit var autoPlayHelper: AutoPlayHelper - /** * Autoplay Preference */ @@ -100,18 +92,21 @@ class BackgroundMode : Service() { */ override fun onCreate() { super.onCreate() - if (Build.VERSION.SDK_INT >= 26) { - val channelId = BACKGROUND_CHANNEL_ID + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( - channelId, + BACKGROUND_CHANNEL_ID, "Background Service", NotificationManager.IMPORTANCE_DEFAULT ) val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager notificationManager.createNotificationChannel(channel) - val notification: Notification = Notification.Builder(this, channelId) + + // see https://developer.android.com/reference/android/app/Service#startForeground(int,%20android.app.Notification) + val notification: Notification = Notification.Builder(this, BACKGROUND_CHANNEL_ID) .setContentTitle(getString(R.string.app_name)) - .setContentText(getString(R.string.playingOnBackground)).build() + .setContentText(getString(R.string.playingOnBackground)) + .build() + startForeground(PLAYER_NOTIFICATION_ID, notification) } } @@ -122,20 +117,21 @@ class BackgroundMode : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { try { // clear the playing queue - PlayingQueue.clear() + PlayingQueue.resetToDefaults() // get the intent arguments videoId = intent?.getStringExtra(IntentData.videoId)!! playlistId = intent.getStringExtra(IntentData.playlistId) val position = intent.getLongExtra(IntentData.position, 0L) - // initialize the playlist autoPlay Helper - autoPlayHelper = AutoPlayHelper(playlistId) - // play the audio in the background loadAudio(videoId, position) - updateWatchPosition() + PlayingQueue.setOnQueueTapListener { streamItem -> + streamItem.url?.toID()?.let { playNextVideo(it) } + } + + if (PlayerHelper.watchPositionsEnabled) updateWatchPosition() } catch (e: Exception) { onDestroy() } @@ -143,7 +139,11 @@ class BackgroundMode : Service() { } private fun updateWatchPosition() { - player?.currentPosition?.let { DatabaseHelper.saveWatchPosition(videoId, it) } + player?.currentPosition?.let { + query { + Database.watchPositionDao().insertAll(WatchPosition(videoId, it)) + } + } handler.postDelayed(this::updateWatchPosition, 500) } @@ -154,8 +154,6 @@ class BackgroundMode : Service() { videoId: String, seekToPosition: Long = 0 ) { - // append the video to the playing queue - PlayingQueue.add(videoId) CoroutineScope(Dispatchers.IO).launch { try { streams = RetrofitInstance.api.getStreams(videoId) @@ -163,6 +161,21 @@ class BackgroundMode : Service() { return@launch } + // add the playlist video to the queue + if (playlistId != null && PlayingQueue.isEmpty()) { + streams?.toStreamItem(videoId) + ?.let { + PlayingQueue.insertPlaylist(playlistId!!, it) + } + } else { + streams?.toStreamItem(videoId)?.let { + PlayingQueue.updateCurrent(it) + } + streams?.relatedStreams?.toTypedArray()?.let { + if (PlayerHelper.autoInsertRelatedVideos) PlayingQueue.add(*it) + } + } + handler.post { playAudio(seekToPosition) } @@ -172,8 +185,6 @@ class BackgroundMode : Service() { private fun playAudio( seekToPosition: Long ) { - PlayingQueue.updateCurrent(videoId) - initializePlayer() setMediaItem() @@ -194,9 +205,8 @@ class BackgroundMode : Service() { } else if (PlayerHelper.watchPositionsEnabled) { try { val watchPosition = awaitQuery { - DatabaseHolder.Database.watchPositionDao().findById(videoId) + Database.watchPositionDao().findById(videoId) } - Log.e("position", watchPosition.toString()) streams?.duration?.let { if (watchPosition != null && watchPosition.position < it * 1000 * 0.9) { player?.seekTo(watchPosition.position) @@ -215,8 +225,6 @@ class BackgroundMode : Service() { player?.setPlaybackSpeed(playbackSpeed) fetchSponsorBlockSegments() - - if (PlayerHelper.autoPlayEnabled) setNextStream() } /** @@ -265,32 +273,16 @@ class BackgroundMode : Service() { }) } - /** - * set the videoId of the next stream for autoplay - */ - private fun setNextStream() { - if (streams!!.relatedStreams!!.isNotEmpty()) { - nextStreamId = streams?.relatedStreams!![0].url!!.toID() - } - - if (playlistId == null) return - if (!this::autoPlayHelper.isInitialized) autoPlayHelper = AutoPlayHelper(playlistId!!) - // search for the next videoId in the playlist - CoroutineScope(Dispatchers.IO).launch { - nextStreamId = autoPlayHelper.getNextVideoId(videoId, streams!!.relatedStreams!!) - } - } - /** * Plays the first related video to the current (used when the playback of the current video ended) */ - private fun playNextVideo() { - if (nextStreamId == null || nextStreamId == videoId) return - val nextQueueVideo = PlayingQueue.getNext() - if (nextQueueVideo != null) nextStreamId = nextQueueVideo + private fun playNextVideo(nextId: String? = null) { + val nextVideo = nextId ?: PlayingQueue.getNext() // play new video on background - this.videoId = nextStreamId!! + if (nextVideo != null) { + this.videoId = nextVideo + } this.segmentData = null loadAudio(videoId) } @@ -322,16 +314,15 @@ class BackgroundMode : Service() { */ private fun fetchSponsorBlockSegments() { CoroutineScope(Dispatchers.IO).launch { - kotlin.runCatching { + runCatching { val categories = PlayerHelper.getSponsorBlockCategories() - if (categories.size > 0) { - segmentData = - RetrofitInstance.api.getSegments( - videoId, - ObjectMapper().writeValueAsString(categories) - ) - checkForSegments() - } + if (categories.isEmpty()) return@runCatching + segmentData = + RetrofitInstance.api.getSegments( + videoId, + ObjectMapper().writeValueAsString(categories) + ) + checkForSegments() } } } @@ -345,7 +336,7 @@ class BackgroundMode : Service() { if (segmentData == null || segmentData!!.segments.isEmpty()) return segmentData!!.segments.forEach { segment: Segment -> - val segmentStart = (segment.segment!![0] * 1000f).toLong() + val segmentStart = (segment.segment[0] * 1000f).toLong() val segmentEnd = (segment.segment[1] * 1000f).toLong() val currentPosition = player?.currentPosition if (currentPosition in segmentStart until segmentEnd) { @@ -367,7 +358,7 @@ class BackgroundMode : Service() { */ override fun onDestroy() { // clear the playing queue - PlayingQueue.clear() + PlayingQueue.resetToDefaults() if (this::nowPlayingNotification.isInitialized) nowPlayingNotification.destroySelfAndPlayer() diff --git a/app/src/main/java/com/github/libretube/services/DownloadService.kt b/app/src/main/java/com/github/libretube/services/DownloadService.kt index dc5893993..ede6e3667 100644 --- a/app/src/main/java/com/github/libretube/services/DownloadService.kt +++ b/app/src/main/java/com/github/libretube/services/DownloadService.kt @@ -15,7 +15,7 @@ import com.github.libretube.R import com.github.libretube.constants.DOWNLOAD_CHANNEL_ID import com.github.libretube.constants.DOWNLOAD_FAILURE_NOTIFICATION_ID import com.github.libretube.constants.DOWNLOAD_SUCCESS_NOTIFICATION_ID -import com.github.libretube.constants.DownloadType +import com.github.libretube.enums.DownloadType import com.github.libretube.extensions.TAG import com.github.libretube.util.DownloadHelper import java.io.File @@ -25,7 +25,7 @@ class DownloadService : Service() { private lateinit var videoName: String private lateinit var videoUrl: String private lateinit var audioUrl: String - private var downloadType: Int = 3 + private var downloadType: DownloadType = DownloadType.NONE private var videoDownloadId: Long? = null private var audioDownloadId: Long? = null @@ -63,8 +63,8 @@ class DownloadService : Service() { private fun downloadManager() { // initialize and create the directories to download into - val videoDownloadDir = DownloadHelper.getVideoDir(this) - val audioDownloadDir = DownloadHelper.getAudioDir(this) + val videoDownloadDir = DownloadHelper.getDownloadDir(this, DownloadHelper.VIDEO_DIR) + val audioDownloadDir = DownloadHelper.getDownloadDir(this, DownloadHelper.AUDIO_DIR) // start download try { @@ -74,7 +74,7 @@ class DownloadService : Service() { ) if (downloadType in listOf(DownloadType.VIDEO, DownloadType.AUDIO_VIDEO)) { videoDownloadId = downloadManagerRequest( - getString(R.string.video), + "[${getString(R.string.video)}] $videoName", getString(R.string.downloading), videoUrl, Uri.fromFile( @@ -84,7 +84,7 @@ class DownloadService : Service() { } if (downloadType in listOf(DownloadType.AUDIO, DownloadType.AUDIO_VIDEO)) { audioDownloadId = downloadManagerRequest( - getString(R.string.audio), + "[${getString(R.string.audio)}] $videoName", getString(R.string.downloading), audioUrl, Uri.fromFile( diff --git a/app/src/main/java/com/github/libretube/services/UpdateService.kt b/app/src/main/java/com/github/libretube/services/UpdateService.kt index 8b3e566de..cc25c9e43 100644 --- a/app/src/main/java/com/github/libretube/services/UpdateService.kt +++ b/app/src/main/java/com/github/libretube/services/UpdateService.kt @@ -11,6 +11,7 @@ import android.os.Environment import android.os.IBinder import android.widget.Toast import com.github.libretube.R +import com.github.libretube.util.DownloadHelper import java.io.File class UpdateService : Service() { @@ -28,9 +29,7 @@ class UpdateService : Service() { } private fun downloadApk(downloadUrl: String) { - val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) - // val dir = applicationContext.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) - file = File(dir, "release.apk") + file = File(getDownloadDirectory(), "release.apk") val request: DownloadManager.Request = DownloadManager.Request(Uri.parse(downloadUrl)) @@ -80,6 +79,12 @@ class UpdateService : Service() { } } + private fun getDownloadDirectory(): File { + val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + if (!downloadsDir.canWrite()) return DownloadHelper.getOfflineStorageDir(this) + return downloadsDir + } + override fun onDestroy() { unregisterReceiver(onDownloadComplete) super.onDestroy() diff --git a/app/src/main/java/com/github/libretube/ui/activities/MainActivity.kt b/app/src/main/java/com/github/libretube/ui/activities/MainActivity.kt index 32286d1dd..08a00e62a 100644 --- a/app/src/main/java/com/github/libretube/ui/activities/MainActivity.kt +++ b/app/src/main/java/com/github/libretube/ui/activities/MainActivity.kt @@ -14,10 +14,10 @@ import android.view.View import android.view.WindowInsets import android.view.WindowInsetsController import android.view.WindowManager -import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.appcompat.widget.SearchView import androidx.core.os.bundleOf +import androidx.core.view.children import androidx.lifecycle.ViewModelProvider import androidx.navigation.NavController import androidx.navigation.findNavController @@ -27,18 +27,20 @@ import com.github.libretube.constants.IntentData import com.github.libretube.constants.PreferenceKeys import com.github.libretube.databinding.ActivityMainBinding import com.github.libretube.extensions.toID -import com.github.libretube.models.PlayerViewModel -import com.github.libretube.models.SearchViewModel -import com.github.libretube.models.SubscriptionsViewModel import com.github.libretube.services.ClosingService import com.github.libretube.ui.base.BaseActivity import com.github.libretube.ui.dialogs.ErrorDialog import com.github.libretube.ui.fragments.PlayerFragment +import com.github.libretube.ui.models.PlayerViewModel +import com.github.libretube.ui.models.SearchViewModel +import com.github.libretube.ui.models.SubscriptionsViewModel +import com.github.libretube.ui.sheets.PlayingQueueSheet +import com.github.libretube.ui.tools.BreakReminder import com.github.libretube.util.NavBarHelper import com.github.libretube.util.NetworkHelper +import com.github.libretube.util.PlayingQueue import com.github.libretube.util.PreferenceHelper import com.github.libretube.util.ThemeHelper -import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.elevation.SurfaceColors class MainActivity : BaseActivity() { @@ -47,21 +49,17 @@ class MainActivity : BaseActivity() { lateinit var navController: NavController private var startFragmentId = R.id.homeFragment - var autoRotationEnabled = false + + val autoRotationEnabled = PreferenceHelper.getBoolean(PreferenceKeys.AUTO_ROTATION, false) lateinit var searchView: SearchView + lateinit var searchItem: MenuItem override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - autoRotationEnabled = PreferenceHelper.getBoolean(PreferenceKeys.AUTO_ROTATION, false) - // enable auto rotation if turned on - requestedOrientation = if (autoRotationEnabled) { - ActivityInfo.SCREEN_ORIENTATION_USER - } else { - ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT - } + requestOrientationChange() // start service that gets called on closure try { @@ -93,8 +91,12 @@ class MainActivity : BaseActivity() { // sets the navigation bar color to the previously calculated color window.navigationBarColor = color - // save start tab fragment id - startFragmentId = NavBarHelper.applyNavBarStyle(binding.bottomNav) + // save start tab fragment id and apply navbar style + startFragmentId = try { + NavBarHelper.applyNavBarStyle(binding.bottomNav) + } catch (e: Exception) { + R.id.homeFragment + } // set default tab as start fragment navController.graph.setStartDestination(startFragmentId) @@ -104,18 +106,16 @@ class MainActivity : BaseActivity() { binding.bottomNav.setOnApplyWindowInsetsListener(null) - binding.bottomNav.setOnItemSelectedListener { - // clear backstack if it's the start fragment - if (startFragmentId == it.itemId) navController.backQueue.clear() - - if (it.itemId == R.id.subscriptionsFragment) { - binding.bottomNav.removeBadge(R.id.subscriptionsFragment) + // Prevent duplicate entries into backstack, if selected item and current + // visible fragment is different, then navigate to selected item. + binding.bottomNav.setOnItemReselectedListener { + if (it.itemId != navController.currentDestination?.id) { + navigateToBottomSelectedItem(it) } + } - removeSearchFocus() - - // navigate to the selected fragment - navController.navigate(it.itemId) + binding.bottomNav.setOnItemSelectedListener { + navigateToBottomSelectedItem(it) false } @@ -127,7 +127,7 @@ class MainActivity : BaseActivity() { val log = PreferenceHelper.getErrorLog() if (log != "") ErrorDialog().show(supportFragmentManager, null) - setupBreakReminder() + BreakReminder.setupBreakReminder(applicationContext) setupSubscriptionsBadge() @@ -143,56 +143,38 @@ class MainActivity : BaseActivity() { } } - if (navController.currentDestination?.id == startFragmentId) { - moveTaskToBack(true) - } else { - navController.popBackStack() + when (navController.currentDestination?.id) { + startFragmentId -> { + moveTaskToBack(true) + } + R.id.searchResultFragment -> { + navController.popBackStack(R.id.searchFragment, true) || + navController.popBackStack() + } + else -> { + navController.popBackStack() + } } } }) + + loadIntentData() } /** - * Show a break reminder when watched too long + * Rotate according to the preference */ - private fun setupBreakReminder() { - if (!PreferenceHelper.getBoolean( - PreferenceKeys.BREAK_REMINDER_TOGGLE, - false - ) - ) { - return + fun requestOrientationChange() { + requestedOrientation = if (autoRotationEnabled) { + ActivityInfo.SCREEN_ORIENTATION_USER + } else { + ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT } - val breakReminderPref = PreferenceHelper.getString( - PreferenceKeys.BREAK_REMINDER, - "0" - ) - if (!breakReminderPref.all { Character.isDigit(it) } || - breakReminderPref == "" || breakReminderPref == "0" - ) { - return - } - Handler(Looper.getMainLooper()).postDelayed( - { - try { - MaterialAlertDialogBuilder(this) - .setTitle(getString(R.string.share_with_time)) - .setMessage( - getString( - R.string.already_spent_time, - breakReminderPref - ) - ) - .setPositiveButton(R.string.okay, null) - .show() - } catch (e: Exception) { - kotlin.runCatching { - Toast.makeText(this, R.string.take_a_break, Toast.LENGTH_LONG).show() - } - } - }, - breakReminderPref.toLong() * 60 * 1000 - ) + } + + override fun onPrepareOptionsMenu(menu: Menu?): Boolean { + menu?.findItem(R.id.action_queue)?.isVisible = PlayingQueue.isNotEmpty() + return super.onPrepareOptionsMenu(menu) } /** @@ -227,6 +209,8 @@ class MainActivity : BaseActivity() { private fun removeSearchFocus() { searchView.setQuery("", false) searchView.clearFocus() + searchView.isIconified = true + searchItem.collapseActionView() searchView.onActionViewCollapsed() } @@ -236,27 +220,31 @@ class MainActivity : BaseActivity() { // stuff for the search in the topBar val searchItem = menu.findItem(R.id.action_search) + this.searchItem = searchItem searchView = searchItem.actionView as SearchView val searchViewModel = ViewModelProvider(this)[SearchViewModel::class.java] - searchView.setOnSearchClickListener { - if (navController.currentDestination?.id != R.id.searchResultFragment) { - searchViewModel.setQuery(null) - navController.navigate(R.id.searchFragment) - } - } - searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { val bundle = Bundle() bundle.putString("query", query) navController.navigate(R.id.searchResultFragment, bundle) searchViewModel.setQuery("") + searchView.clearFocus() return true } override fun onQueryTextChange(newText: String?): Boolean { + // Prevent navigation when search view is collapsed + if (searchView.isIconified || + binding.bottomNav.menu.children.any { + it.itemId == navController.currentDestination?.id + } + ) { + return true + } + // prevent malicious navigation when the search view is getting collapsed if (navController.currentDestination?.id in listOf( R.id.searchResultFragment, @@ -279,6 +267,36 @@ class MainActivity : BaseActivity() { return true } }) + + searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { + override fun onMenuItemActionExpand(item: MenuItem): Boolean { + if (navController.currentDestination?.id != R.id.searchResultFragment) { + searchViewModel.setQuery(null) + navController.navigate(R.id.searchFragment) + } + item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS or MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW) + return true + } + + override fun onMenuItemActionCollapse(item: MenuItem): Boolean { + if (binding.mainMotionLayout.progress == 0F) { + try { + minimizePlayer() + } catch (e: Exception) { + // current fragment isn't the player fragment + } + } + // Handover back press to `BackPressedDispatcher` + else if (binding.bottomNav.menu.children.none { + it.itemId == navController.currentDestination?.id + } + ) { + this@MainActivity.onBackPressedDispatcher.onBackPressed() + } + + return true + } + }) return super.onCreateOptionsMenu(menu) } @@ -302,16 +320,14 @@ class MainActivity : BaseActivity() { startActivity(communityIntent) true } + R.id.action_queue -> { + PlayingQueueSheet().show(supportFragmentManager, null) + true + } else -> super.onOptionsItemSelected(item) } } - override fun onStart() { - super.onStart() - // check whether an URI got submitted over the intent data and load it - loadIntentData() - } - private fun loadIntentData() { intent?.getStringExtra(IntentData.channelId)?.let { navController.navigate( @@ -334,6 +350,20 @@ class MainActivity : BaseActivity() { intent?.getStringExtra(IntentData.videoId)?.let { loadVideo(it, intent?.getLongExtra(IntentData.timeStamp, 0L)) } + when (intent?.getStringExtra("fragmentToOpen")) { + "home" -> + navController.navigate(R.id.homeFragment) + "trends" -> + navController.navigate(R.id.trendsFragment) + "subscriptions" -> + navController.navigate(R.id.subscriptionsFragment) + "library" -> + navController.navigate(R.id.libraryFragment) + } + if (intent?.getBooleanExtra(IntentData.openQueueOnce, false) == true) { + PlayingQueueSheet() + .show(supportFragmentManager) + } } private fun loadVideo(videoId: String, timeStamp: Long?) { @@ -359,7 +389,7 @@ class MainActivity : BaseActivity() { transitionToStart() } } - }, 100) + }, 300) } private fun minimizePlayer() { @@ -459,6 +489,26 @@ class MainActivity : BaseActivity() { } } + private fun navigateToBottomSelectedItem(item: MenuItem) { + // clear backstack if it's the start fragment + if (startFragmentId == item.itemId) navController.backQueue.clear() + + if (item.itemId == R.id.subscriptionsFragment) { + binding.bottomNav.removeBadge(R.id.subscriptionsFragment) + } + + // navigate to the selected fragment, if the fragment already + // exists in backstack then pop up to that entry + if (!navController.popBackStack(item.itemId, false)) { + navController.navigate(item.itemId) + } + + // Remove focus from search view when navigating to bottom view. + // Call only after navigate to destination, so it can be used in + // onMenuItemActionCollapse for backstack management + removeSearchFocus() + } + override fun onUserLeaveHint() { super.onUserLeaveHint() supportFragmentManager.fragments.forEach { fragment -> diff --git a/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt b/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt index 2e717ba73..4f6d26638 100644 --- a/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt +++ b/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt @@ -68,28 +68,26 @@ class OfflinePlayerActivity : BaseActivity() { } binding.player.initialize( - supportFragmentManager, null, binding.doubleTapOverlay.binding, null ) } + private fun File.toUri(): Uri? { + return if (this.exists()) Uri.fromFile(this) else null + } + private fun playVideo() { - val videoDownloadDir = DownloadHelper.getVideoDir(this) - val videoFile = File( - videoDownloadDir, + val videoUri = File( + DownloadHelper.getDownloadDir(this, DownloadHelper.VIDEO_DIR), fileName - ) + ).toUri() - val audioDownloadDir = DownloadHelper.getAudioDir(this) - val audioFile = File( - audioDownloadDir, + val audioUri = File( + DownloadHelper.getDownloadDir(this, DownloadHelper.AUDIO_DIR), fileName - ) - - val videoUri = if (videoFile.exists()) Uri.fromFile(videoFile) else null - val audioUri = if (audioFile.exists()) Uri.fromFile(audioFile) else null + ).toUri() setMediaSource( videoUri, diff --git a/app/src/main/java/com/github/libretube/ui/activities/RouterActivity.kt b/app/src/main/java/com/github/libretube/ui/activities/RouterActivity.kt index e66f02fd2..e231584e6 100644 --- a/app/src/main/java/com/github/libretube/ui/activities/RouterActivity.kt +++ b/app/src/main/java/com/github/libretube/ui/activities/RouterActivity.kt @@ -10,6 +10,7 @@ import com.github.libretube.constants.IntentData import com.github.libretube.extensions.TAG import com.github.libretube.ui.base.BaseActivity import com.github.libretube.util.NavigationHelper +import kotlin.time.Duration class RouterActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -75,14 +76,14 @@ class RouterActivity : BaseActivity() { intent.putExtra(IntentData.videoId, videoId) uri.getQueryParameter("t") - ?.let { intent.putExtra(IntentData.timeStamp, it.toLong()) } + ?.let { intent.putExtra(IntentData.timeStamp, parseTimestamp(it)) } } else -> { val videoId = uri.path!!.replace("/", "") intent.putExtra(IntentData.videoId, videoId) uri.getQueryParameter("t") - ?.let { intent.putExtra(IntentData.timeStamp, it.toLong()) } + ?.let { intent.putExtra(IntentData.timeStamp, parseTimestamp(it)) } } } return intent @@ -99,4 +100,12 @@ class RouterActivity : BaseActivity() { ) this.finishAndRemoveTask() } + + private fun parseTimestamp(t: String): Long? { + if (t.all { c -> c.isDigit() }) { + return t.toLong() + } + + return Duration.parseOrNull(t)?.inWholeSeconds + } } diff --git a/app/src/main/java/com/github/libretube/ui/adapters/BottomSheetAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/BottomSheetAdapter.kt index e694e8f5a..f17ca2535 100644 --- a/app/src/main/java/com/github/libretube/ui/adapters/BottomSheetAdapter.kt +++ b/app/src/main/java/com/github/libretube/ui/adapters/BottomSheetAdapter.kt @@ -24,16 +24,17 @@ class BottomSheetAdapter( override fun onBindViewHolder(holder: BottomSheetViewHolder, position: Int) { val item = items[position] holder.binding.apply { + val current = item.getCurrent() title.text = - if (item.currentValue != null) "${item.title} (${item.currentValue})" else item.title + if (current != null) "${item.title} ($current)" else item.title if (item.drawable != null) { drawable.setImageResource(item.drawable) } else { - drawable.visibility = - View.GONE + drawable.visibility = View.GONE } root.setOnClickListener { + item.onClick.invoke() listener.invoke(position) } } diff --git a/app/src/main/java/com/github/libretube/ui/adapters/ChannelAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/ChannelAdapter.kt deleted file mode 100644 index d0467ad4b..000000000 --- a/app/src/main/java/com/github/libretube/ui/adapters/ChannelAdapter.kt +++ /dev/null @@ -1,78 +0,0 @@ -package com.github.libretube.ui.adapters - -import android.annotation.SuppressLint -import android.text.format.DateUtils -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.fragment.app.FragmentManager -import androidx.recyclerview.widget.RecyclerView -import com.github.libretube.R -import com.github.libretube.api.obj.StreamItem -import com.github.libretube.databinding.VideoRowBinding -import com.github.libretube.extensions.formatShort -import com.github.libretube.extensions.setWatchProgressLength -import com.github.libretube.extensions.toID -import com.github.libretube.ui.sheets.VideoOptionsBottomSheet -import com.github.libretube.ui.viewholders.ChannelViewHolder -import com.github.libretube.util.ImageHelper -import com.github.libretube.util.NavigationHelper - -class ChannelAdapter( - private val videoFeed: MutableList, - private val childFragmentManager: FragmentManager, - private val showChannelInfo: Boolean = false -) : - RecyclerView.Adapter() { - - override fun getItemCount(): Int { - return videoFeed.size - } - - fun updateItems(newItems: List) { - val feedSize = videoFeed.size - videoFeed.addAll(newItems) - notifyItemRangeInserted(feedSize, newItems.size) - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChannelViewHolder { - val layoutInflater = LayoutInflater.from(parent.context) - val binding = VideoRowBinding.inflate(layoutInflater, parent, false) - return ChannelViewHolder(binding) - } - - @SuppressLint("SetTextI18n") - override fun onBindViewHolder(holder: ChannelViewHolder, position: Int) { - val video = videoFeed[position] - holder.binding.apply { - videoTitle.text = video.title - - videoInfo.text = - video.views.formatShort() + " " + - root.context.getString(R.string.views_placeholder) + - " • " + DateUtils.getRelativeTimeSpanString(video.uploaded!!) - - thumbnailDuration.text = - DateUtils.formatElapsedTime(video.duration!!) - - ImageHelper.loadImage(video.thumbnail, thumbnail) - - if (showChannelInfo) { - ImageHelper.loadImage(video.uploaderAvatar, channelImage) - channelName.text = video.uploaderName - } - - root.setOnClickListener { - NavigationHelper.navigateVideo(root.context, video.url) - } - - val videoId = video.url!!.toID() - root.setOnLongClickListener { - VideoOptionsBottomSheet(videoId) - .show(childFragmentManager, VideoOptionsBottomSheet::class.java.name) - true - } - - watchProgress.setWatchProgressLength(videoId, video.duration!!) - } - } -} diff --git a/app/src/main/java/com/github/libretube/ui/adapters/ChaptersAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/ChaptersAdapter.kt index b0c65a972..8b7f64a68 100644 --- a/app/src/main/java/com/github/libretube/ui/adapters/ChaptersAdapter.kt +++ b/app/src/main/java/com/github/libretube/ui/adapters/ChaptersAdapter.kt @@ -1,9 +1,11 @@ package com.github.libretube.ui.adapters import android.graphics.Color +import android.text.format.DateUtils import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView +import com.github.libretube.api.obj.ChapterSegment import com.github.libretube.databinding.ChapterColumnBinding import com.github.libretube.ui.viewholders.ChaptersViewHolder import com.github.libretube.util.ImageHelper @@ -11,7 +13,7 @@ import com.github.libretube.util.ThemeHelper import com.google.android.exoplayer2.ExoPlayer class ChaptersAdapter( - private val chapters: List, + private val chapters: List, private val exoPlayer: ExoPlayer ) : RecyclerView.Adapter() { private var selectedPosition = 0 @@ -27,15 +29,15 @@ class ChaptersAdapter( holder.binding.apply { ImageHelper.loadImage(chapter.image, chapterImage) chapterTitle.text = chapter.title + timeStamp.text = chapter.start?.let { DateUtils.formatElapsedTime(it) } - if (selectedPosition == position) { - // get the color for highlighted controls - val color = - ThemeHelper.getThemeColor(root.context, android.R.attr.colorControlHighlight) - chapterLL.setBackgroundColor(color) + val color = if (selectedPosition == position) { + ThemeHelper.getThemeColor(root.context, android.R.attr.colorControlHighlight) } else { - chapterLL.setBackgroundColor(Color.TRANSPARENT) + Color.TRANSPARENT } + chapterLL.setBackgroundColor(color) + root.setOnClickListener { updateSelectedPosition(position) val chapterStart = chapter.start!! * 1000 // s -> ms diff --git a/app/src/main/java/com/github/libretube/ui/adapters/CommentsAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/CommentsAdapter.kt index c54d089ae..330f6e543 100644 --- a/app/src/main/java/com/github/libretube/ui/adapters/CommentsAdapter.kt +++ b/app/src/main/java/com/github/libretube/ui/adapters/CommentsAdapter.kt @@ -5,6 +5,7 @@ import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Button import android.widget.Toast import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -19,6 +20,7 @@ import com.github.libretube.ui.viewholders.CommentsViewHolder import com.github.libretube.util.ClipboardHelper import com.github.libretube.util.ImageHelper import com.github.libretube.util.NavigationHelper +import com.github.libretube.util.TextUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -60,7 +62,7 @@ class CommentsAdapter( root.scaleY = 0.9f } - commentInfos.text = comment.author.toString() + " • " + comment.commentedTime.toString() + commentInfos.text = comment.author.toString() + TextUtils.SEPARATOR + comment.commentedTime.toString() commentText.text = comment.commentText.toString() ImageHelper.loadImage(comment.thumbnail, commentorImage) @@ -71,8 +73,7 @@ class CommentsAdapter( if (comment.hearted == true) heartedImageView.visibility = View.VISIBLE if (comment.repliesPage != null) repliesAvailable.visibility = View.VISIBLE if ((comment.replyCount ?: -1L) > 0L) { - repliesCount.text = - comment.replyCount?.formatShort() + repliesCount.text = comment.replyCount?.formatShort() } commentorImage.setOnClickListener { @@ -89,30 +90,7 @@ class CommentsAdapter( repliesRecView.adapter = repliesAdapter if (!isRepliesAdapter && comment.repliesPage != null) { root.setOnClickListener { - when { - repliesAdapter.itemCount.equals(0) -> { - fetchReplies(comment.repliesPage) { - repliesAdapter.updateItems(it.comments) - if (repliesPage.nextpage == null) { - showMore.visibility = View.GONE - return@fetchReplies - } - showMore.visibility = View.VISIBLE - showMore.setOnClickListener { - if (repliesPage.nextpage == null) { - it.visibility = View.GONE - return@setOnClickListener - } - fetchReplies( - repliesPage.nextpage!! - ) { - repliesAdapter.updateItems(repliesPage.comments) - } - } - } - } - else -> repliesAdapter.clear() - } + showMoreReplies(comment.repliesPage, showMore, repliesAdapter) } } @@ -124,6 +102,36 @@ class CommentsAdapter( } } + private fun showMoreReplies(nextPage: String, showMoreBtn: Button, repliesAdapter: CommentsAdapter) { + when { + repliesAdapter.itemCount.equals(0) -> { + fetchReplies(nextPage) { + repliesAdapter.updateItems(it.comments) + if (repliesPage.nextpage == null) { + showMoreBtn.visibility = View.GONE + return@fetchReplies + } + showMoreBtn.visibility = View.VISIBLE + showMoreBtn.setOnClickListener { + if (repliesPage.nextpage == null) { + it.visibility = View.GONE + return@setOnClickListener + } + fetchReplies( + repliesPage.nextpage!! + ) { + repliesAdapter.updateItems(repliesPage.comments) + } + } + } + } + else -> { + repliesAdapter.clear() + showMoreBtn.visibility = View.GONE + } + } + } + override fun getItemCount(): Int { return comments.size } diff --git a/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt index b1f35725b..3ecbcb932 100644 --- a/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt +++ b/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt @@ -13,6 +13,7 @@ import com.github.libretube.obj.DownloadedFile import com.github.libretube.ui.activities.OfflinePlayerActivity import com.github.libretube.ui.viewholders.DownloadsViewHolder import com.github.libretube.util.DownloadHelper +import com.github.libretube.util.TextUtils import com.google.android.material.dialog.MaterialAlertDialogBuilder import java.io.File @@ -39,7 +40,7 @@ class DownloadsAdapter( uploaderName.text = it.uploader videoInfo.text = it.views.formatShort() + " " + root.context.getString(R.string.views_placeholder) + - " • " + it.uploadDate + TextUtils.SEPARATOR + it.uploadDate } thumbnailImage.setImageBitmap(file.thumbnail) @@ -60,8 +61,8 @@ class DownloadsAdapter( ) { _, index -> when (index) { 0 -> { - val audioDir = DownloadHelper.getAudioDir(root.context) - val videoDir = DownloadHelper.getVideoDir(root.context) + val audioDir = DownloadHelper.getDownloadDir(root.context, DownloadHelper.AUDIO_DIR) + val videoDir = DownloadHelper.getDownloadDir(root.context, DownloadHelper.VIDEO_DIR) listOf(audioDir, videoDir).forEach { val f = File(it, file.name) diff --git a/app/src/main/java/com/github/libretube/ui/adapters/IconsSheetAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/IconsSheetAdapter.kt new file mode 100644 index 000000000..a1805b683 --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/adapters/IconsSheetAdapter.kt @@ -0,0 +1,72 @@ +package com.github.libretube.ui.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.recyclerview.widget.RecyclerView +import com.github.libretube.R +import com.github.libretube.constants.PreferenceKeys +import com.github.libretube.databinding.AppIconItemBinding +import com.github.libretube.ui.viewholders.IconsSheetViewHolder +import com.github.libretube.util.PreferenceHelper +import com.github.libretube.util.ThemeHelper + +class IconsSheetAdapter : RecyclerView.Adapter() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): IconsSheetViewHolder { + val binding = AppIconItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return IconsSheetViewHolder(binding) + } + + override fun getItemCount(): Int { + return availableIcons.size + } + + override fun onBindViewHolder(holder: IconsSheetViewHolder, position: Int) { + val appIcon = availableIcons[position] + holder.binding.apply { + iconIV.setImageResource(appIcon.iconResource) + iconName.text = root.context.getString(appIcon.nameResource) + root.setOnClickListener { + PreferenceHelper.putString(PreferenceKeys.APP_ICON, appIcon.activityAlias) + ThemeHelper.changeIcon(root.context, appIcon.activityAlias) + } + } + } + + companion object { + sealed class AppIcon( + @StringRes val nameResource: Int, + @DrawableRes val iconResource: Int, + val activityAlias: String + ) { + object Default : + AppIcon(R.string.defaultIcon, R.mipmap.ic_launcher, "ui.activities.MainActivity") + + object DefaultLight : + AppIcon(R.string.defaultIconLight, R.mipmap.ic_launcher_light, "DefaultLight") + + object Legacy : AppIcon(R.string.legacyIcon, R.mipmap.ic_legacy, "IconLegacy") + object Gradient : + AppIcon(R.string.gradientIcon, R.mipmap.ic_gradient, "IconGradient") + + object Fire : AppIcon(R.string.fireIcon, R.mipmap.ic_fire, "IconFire") + object Torch : AppIcon(R.string.torchIcon, R.mipmap.ic_torch, "IconTorch") + object Shaped : AppIcon(R.string.shapedIcon, R.mipmap.ic_shaped, "IconShaped") + object Flame : AppIcon(R.string.flameIcon, R.mipmap.ic_flame, "IconFlame") + object Bird : AppIcon(R.string.birdIcon, R.mipmap.ic_bird, "IconBird") + } + + val availableIcons = listOf( + AppIcon.Default, + AppIcon.DefaultLight, + AppIcon.Legacy, + AppIcon.Gradient, + AppIcon.Fire, + AppIcon.Torch, + AppIcon.Shaped, + AppIcon.Flame, + AppIcon.Bird + ) + } +} diff --git a/app/src/main/java/com/github/libretube/ui/adapters/NavBarOptionsAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/NavBarOptionsAdapter.kt index 41fbad960..cdb366228 100644 --- a/app/src/main/java/com/github/libretube/ui/adapters/NavBarOptionsAdapter.kt +++ b/app/src/main/java/com/github/libretube/ui/adapters/NavBarOptionsAdapter.kt @@ -1,16 +1,16 @@ package com.github.libretube.ui.adapters import android.view.LayoutInflater +import android.view.MenuItem import android.view.ViewGroup import android.widget.Toast import androidx.recyclerview.widget.RecyclerView import com.github.libretube.R import com.github.libretube.databinding.NavOptionsItemBinding -import com.github.libretube.obj.NavBarItem import com.github.libretube.ui.viewholders.NavBarOptionsViewHolder class NavBarOptionsAdapter( - val items: MutableList + val items: MutableList ) : RecyclerView.Adapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NavBarOptionsViewHolder { @@ -30,9 +30,9 @@ class NavBarOptionsAdapter( val item = items[position] holder.binding.apply { title.text = item.title - checkbox.isChecked = item.isEnabled + checkbox.isChecked = item.isVisible checkbox.setOnClickListener { - if (!checkbox.isChecked && getEnabledItemsCount() < 2) { + if (!checkbox.isChecked && getVisibleItemsCount() < 2) { checkbox.isChecked = true Toast.makeText( root.context, @@ -41,12 +41,12 @@ class NavBarOptionsAdapter( ).show() return@setOnClickListener } - items[position].isEnabled = checkbox.isChecked + items[position].isVisible = checkbox.isChecked } } } - private fun getEnabledItemsCount(): Int { - return items.filter { it.isEnabled }.size + private fun getVisibleItemsCount(): Int { + return items.filter { it.isVisible }.size } } diff --git a/app/src/main/java/com/github/libretube/ui/adapters/PlayingQueueAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/PlayingQueueAdapter.kt new file mode 100644 index 000000000..20ff28c2c --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/adapters/PlayingQueueAdapter.kt @@ -0,0 +1,56 @@ +package com.github.libretube.ui.adapters + +import android.annotation.SuppressLint +import android.graphics.Color +import android.text.format.DateUtils +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.github.libretube.databinding.QueueRowBinding +import com.github.libretube.ui.viewholders.PlayingQueueViewHolder +import com.github.libretube.util.ImageHelper +import com.github.libretube.util.PlayingQueue +import com.github.libretube.util.ThemeHelper + +class PlayingQueueAdapter : RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PlayingQueueViewHolder { + val binding = QueueRowBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return PlayingQueueViewHolder(binding) + } + + override fun getItemCount(): Int { + return PlayingQueue.size() + } + + @SuppressLint("SetTextI18n") + override fun onBindViewHolder(holder: PlayingQueueViewHolder, position: Int) { + val streamItem = PlayingQueue.getStreams()[position] + holder.binding.apply { + ImageHelper.loadImage(streamItem.thumbnail, thumbnail) + title.text = streamItem.title + videoInfo.text = streamItem.uploaderName + " • " + + DateUtils.formatElapsedTime(streamItem.duration ?: 0) + + val currentIndex = PlayingQueue.currentIndex() + root.setBackgroundColor( + if (currentIndex == position) { + ThemeHelper.getThemeColor(root.context, android.R.attr.colorControlHighlight) + } else { + Color.TRANSPARENT + } + ) + + root.setOnClickListener { + val oldIndex = PlayingQueue.currentIndex() + PlayingQueue.onQueueItemSelected(position) + notifyItemChanged(oldIndex) + notifyItemChanged(position) + } + } + } +} diff --git a/app/src/main/java/com/github/libretube/ui/adapters/PlaylistAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/PlaylistAdapter.kt index 0839cf3d9..3291e36ff 100644 --- a/app/src/main/java/com/github/libretube/ui/adapters/PlaylistAdapter.kt +++ b/app/src/main/java/com/github/libretube/ui/adapters/PlaylistAdapter.kt @@ -1,42 +1,41 @@ package com.github.libretube.ui.adapters import android.app.Activity +import android.content.Context import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.fragment.app.FragmentManager import androidx.recyclerview.widget.RecyclerView -import com.github.libretube.api.RetrofitInstance +import com.github.libretube.api.PlaylistsHelper +import com.github.libretube.api.obj.StreamItem import com.github.libretube.databinding.PlaylistRowBinding +import com.github.libretube.enums.PlaylistType import com.github.libretube.extensions.TAG -import com.github.libretube.extensions.setFormattedDuration -import com.github.libretube.extensions.setWatchProgressLength import com.github.libretube.extensions.toID +import com.github.libretube.ui.base.BaseActivity +import com.github.libretube.ui.extensions.setFormattedDuration +import com.github.libretube.ui.extensions.setWatchProgressLength import com.github.libretube.ui.sheets.VideoOptionsBottomSheet import com.github.libretube.ui.viewholders.PlaylistViewHolder import com.github.libretube.util.ImageHelper import com.github.libretube.util.NavigationHelper -import com.github.libretube.util.PreferenceHelper import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import retrofit2.HttpException import java.io.IOException class PlaylistAdapter( - private val videoFeed: MutableList, + private val videoFeed: MutableList, private val playlistId: String, - private val isOwner: Boolean, - private val activity: Activity, - private val childFragmentManager: FragmentManager + private val playlistType: PlaylistType ) : RecyclerView.Adapter() { override fun getItemCount(): Int { return videoFeed.size } - fun updateItems(newItems: List) { + fun updateItems(newItems: List) { val oldSize = videoFeed.size videoFeed.addAll(newItems) notifyItemRangeInserted(oldSize, videoFeed.size) @@ -59,40 +58,37 @@ class PlaylistAdapter( NavigationHelper.navigateVideo(root.context, streamItem.url, playlistId) } val videoId = streamItem.url!!.toID() + val videoName = streamItem.title!! root.setOnLongClickListener { - VideoOptionsBottomSheet(videoId) - .show(childFragmentManager, VideoOptionsBottomSheet::class.java.name) + VideoOptionsBottomSheet(videoId, videoName) + .show( + (root.context as BaseActivity).supportFragmentManager, + VideoOptionsBottomSheet::class.java.name + ) true } - if (isOwner) { + if (playlistType != PlaylistType.PUBLIC) { deletePlaylist.visibility = View.VISIBLE deletePlaylist.setOnClickListener { - removeFromPlaylist(position) + removeFromPlaylist(root.context, position) } } watchProgress.setWatchProgressLength(videoId, streamItem.duration!!) } } - fun removeFromPlaylist(position: Int) { + fun removeFromPlaylist(context: Context, position: Int) { videoFeed.removeAt(position) - activity.runOnUiThread { notifyDataSetChanged() } + (context as Activity).runOnUiThread { + notifyItemRemoved(position) + notifyItemRangeChanged(position, itemCount) + } CoroutineScope(Dispatchers.IO).launch { try { - RetrofitInstance.authApi.removeFromPlaylist( - PreferenceHelper.getToken(), - com.github.libretube.api.obj.PlaylistId( - playlistId = playlistId, - index = position - ) - ) + PlaylistsHelper.removeFromPlaylist(playlistId, position) } catch (e: IOException) { - println(e) - Log.e(TAG(), "IOException, you might not have internet connection") - return@launch - } catch (e: HttpException) { - Log.e(TAG(), "HttpException, unexpected response") + Log.e(TAG(), e.toString()) return@launch } } diff --git a/app/src/main/java/com/github/libretube/ui/adapters/PlaylistBookmarkAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/PlaylistBookmarkAdapter.kt new file mode 100644 index 000000000..3dc6be6f6 --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/adapters/PlaylistBookmarkAdapter.kt @@ -0,0 +1,65 @@ +package com.github.libretube.ui.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.recyclerview.widget.RecyclerView +import com.github.libretube.databinding.PlaylistBookmarkRowBinding +import com.github.libretube.db.obj.PlaylistBookmark +import com.github.libretube.enums.PlaylistType +import com.github.libretube.extensions.toDp +import com.github.libretube.ui.sheets.PlaylistOptionsBottomSheet +import com.github.libretube.ui.viewholders.PlaylistBookmarkViewHolder +import com.github.libretube.util.ImageHelper +import com.github.libretube.util.NavigationHelper + +class PlaylistBookmarkAdapter( + private val bookmarks: List, + private val bookmarkMode: BookmarkMode = BookmarkMode.FRAGMENT +) : RecyclerView.Adapter() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PlaylistBookmarkViewHolder { + val binding = PlaylistBookmarkRowBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return PlaylistBookmarkViewHolder(binding) + } + + override fun getItemCount(): Int { + return bookmarks.size + } + + override fun onBindViewHolder(holder: PlaylistBookmarkViewHolder, position: Int) { + val bookmark = bookmarks[position] + holder.binding.apply { + if (bookmarkMode == BookmarkMode.HOME) { + val params = root.layoutParams + params.width = (210).toDp(root.context.resources).toInt() + root.layoutParams = params + } + + ImageHelper.loadImage(bookmark.thumbnailUrl, thumbnail) + playlistName.text = bookmark.playlistName + uploaderName.text = bookmark.uploader + + root.setOnClickListener { + NavigationHelper.navigatePlaylist(root.context, bookmark.playlistId, PlaylistType.PUBLIC) + } + + root.setOnLongClickListener { + PlaylistOptionsBottomSheet( + playlistId = bookmark.playlistId, + playlistName = bookmark.playlistName ?: "", + playlistType = PlaylistType.PUBLIC + ).show( + (root.context as AppCompatActivity).supportFragmentManager + ) + true + } + } + } + + companion object { + enum class BookmarkMode { + HOME, + FRAGMENT + } + } +} diff --git a/app/src/main/java/com/github/libretube/ui/adapters/PlaylistsAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/PlaylistsAdapter.kt index 65aeb5a95..63dba1787 100644 --- a/app/src/main/java/com/github/libretube/ui/adapters/PlaylistsAdapter.kt +++ b/app/src/main/java/com/github/libretube/ui/adapters/PlaylistsAdapter.kt @@ -1,38 +1,29 @@ package com.github.libretube.ui.adapters -import android.app.Activity -import android.util.Log import android.view.LayoutInflater import android.view.ViewGroup -import androidx.fragment.app.FragmentManager import androidx.recyclerview.widget.RecyclerView import com.github.libretube.R -import com.github.libretube.api.RetrofitInstance +import com.github.libretube.api.obj.Playlists import com.github.libretube.databinding.PlaylistsRowBinding -import com.github.libretube.extensions.TAG +import com.github.libretube.enums.PlaylistType +import com.github.libretube.ui.base.BaseActivity +import com.github.libretube.ui.dialogs.DeletePlaylistDialog import com.github.libretube.ui.sheets.PlaylistOptionsBottomSheet import com.github.libretube.ui.viewholders.PlaylistsViewHolder import com.github.libretube.util.ImageHelper import com.github.libretube.util.NavigationHelper -import com.github.libretube.util.PreferenceHelper -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import retrofit2.HttpException -import java.io.IOException class PlaylistsAdapter( - private val playlists: MutableList, - private val childFragmentManager: FragmentManager, - private val activity: Activity + private val playlists: MutableList, + private val playlistType: PlaylistType ) : RecyclerView.Adapter() { override fun getItemCount(): Int { return playlists.size } - fun updateItems(newItems: List) { + fun updateItems(newItems: List) { val oldSize = playlists.size playlists.addAll(newItems) notifyItemRangeInserted(oldSize, playlists.size) @@ -55,61 +46,37 @@ class PlaylistsAdapter( ImageHelper.loadImage(playlist.thumbnail, playlistThumbnail) } playlistTitle.text = playlist.name + + videoCount.text = playlist.videos.toString() + deletePlaylist.setOnClickListener { - val builder = MaterialAlertDialogBuilder(root.context) - builder.setTitle(R.string.deletePlaylist) - builder.setMessage(R.string.areYouSure) - builder.setPositiveButton(R.string.yes) { _, _ -> - PreferenceHelper.getToken() - deletePlaylist(playlist.id!!, position) - } - builder.setNegativeButton(R.string.cancel, null) - builder.show() + DeletePlaylistDialog(playlist.id!!, playlistType) { + playlists.removeAt(position) + (root.context as BaseActivity).runOnUiThread { + notifyItemRemoved(position) + notifyItemRangeChanged(position, itemCount) + } + }.show( + (root.context as BaseActivity).supportFragmentManager, + null + ) } root.setOnClickListener { - NavigationHelper.navigatePlaylist(root.context, playlist.id, true) + NavigationHelper.navigatePlaylist(root.context, playlist.id, playlistType) } root.setOnLongClickListener { val playlistOptionsDialog = PlaylistOptionsBottomSheet( playlistId = playlist.id!!, - isOwner = true + playlistName = playlist.name!!, + playlistType = playlistType ) playlistOptionsDialog.show( - childFragmentManager, + (root.context as BaseActivity).supportFragmentManager, PlaylistOptionsBottomSheet::class.java.name ) true } } } - - private fun deletePlaylist(id: String, position: Int) { - CoroutineScope(Dispatchers.IO).launch { - val response = try { - RetrofitInstance.authApi.deletePlaylist( - PreferenceHelper.getToken(), - com.github.libretube.api.obj.PlaylistId(id) - ) - } catch (e: IOException) { - println(e) - Log.e(TAG(), "IOException, you might not have internet connection") - return@launch - } catch (e: HttpException) { - Log.e(TAG(), "HttpException, unexpected response") - return@launch - } - try { - if (response.message == "ok") { - playlists.removeAt(position) - activity.runOnUiThread { - notifyItemRemoved(position) - notifyItemRangeChanged(position, itemCount) - } - } - } catch (e: Exception) { - Log.e(TAG(), e.toString()) - } - } - } } diff --git a/app/src/main/java/com/github/libretube/ui/adapters/SearchAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/SearchAdapter.kt index fcc71b98e..671efeade 100644 --- a/app/src/main/java/com/github/libretube/ui/adapters/SearchAdapter.kt +++ b/app/src/main/java/com/github/libretube/ui/adapters/SearchAdapter.kt @@ -4,34 +4,32 @@ import android.annotation.SuppressLint import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.fragment.app.FragmentManager import androidx.recyclerview.widget.RecyclerView import com.github.libretube.R -import com.github.libretube.api.SubscriptionHelper -import com.github.libretube.api.obj.SearchItem +import com.github.libretube.api.obj.ContentItem import com.github.libretube.databinding.ChannelRowBinding -import com.github.libretube.databinding.PlaylistSearchRowBinding +import com.github.libretube.databinding.PlaylistsRowBinding import com.github.libretube.databinding.VideoRowBinding +import com.github.libretube.enums.PlaylistType import com.github.libretube.extensions.formatShort -import com.github.libretube.extensions.setFormattedDuration -import com.github.libretube.extensions.setWatchProgressLength import com.github.libretube.extensions.toID +import com.github.libretube.ui.base.BaseActivity +import com.github.libretube.ui.extensions.setFormattedDuration +import com.github.libretube.ui.extensions.setWatchProgressLength +import com.github.libretube.ui.extensions.setupSubscriptionButton import com.github.libretube.ui.sheets.PlaylistOptionsBottomSheet import com.github.libretube.ui.sheets.VideoOptionsBottomSheet import com.github.libretube.ui.viewholders.SearchViewHolder import com.github.libretube.util.ImageHelper import com.github.libretube.util.NavigationHelper -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch +import com.github.libretube.util.TextUtils class SearchAdapter( - private val searchItems: MutableList, - private val childFragmentManager: FragmentManager + private val searchItems: MutableList ) : RecyclerView.Adapter() { - fun updateItems(newItems: List) { + fun updateItems(newItems: List) { val searchItemsSize = searchItems.size searchItems.addAll(newItems) notifyItemRangeInserted(searchItemsSize, newItems.size) @@ -52,7 +50,7 @@ class SearchAdapter( ChannelRowBinding.inflate(layoutInflater, parent, false) ) 2 -> SearchViewHolder( - PlaylistSearchRowBinding.inflate(layoutInflater, parent, false) + PlaylistsRowBinding.inflate(layoutInflater, parent, false) ) else -> throw IllegalArgumentException("Invalid type") } @@ -81,7 +79,7 @@ class SearchAdapter( } } - private fun bindWatch(item: SearchItem, binding: VideoRowBinding) { + private fun bindWatch(item: ContentItem, binding: VideoRowBinding) { binding.apply { ImageHelper.loadImage(item.thumbnail, thumbnail) thumbnailDuration.setFormattedDuration(item.duration!!) @@ -100,12 +98,13 @@ class SearchAdapter( NavigationHelper.navigateVideo(root.context, item.url) } val videoId = item.url!!.toID() + val videoName = item.title!! root.setOnLongClickListener { - VideoOptionsBottomSheet(videoId) - .show(childFragmentManager, VideoOptionsBottomSheet::class.java.name) + VideoOptionsBottomSheet(videoId, videoName) + .show((root.context as BaseActivity).supportFragmentManager, VideoOptionsBottomSheet::class.java.name) true } - channelImage.setOnClickListener { + channelContainer.setOnClickListener { NavigationHelper.navigateChannel(root.context, item.uploaderUrl) } watchProgress.setWatchProgressLength(videoId, item.duration!!) @@ -114,7 +113,7 @@ class SearchAdapter( @SuppressLint("SetTextI18n") private fun bindChannel( - item: SearchItem, + item: ContentItem, binding: ChannelRowBinding ) { binding.apply { @@ -123,66 +122,33 @@ class SearchAdapter( searchViews.text = root.context.getString( R.string.subscribers, item.subscribers.formatShort() - ) + " • " + root.context.getString(R.string.videoCount, item.videos.toString()) + ) + TextUtils.SEPARATOR + root.context.getString(R.string.videoCount, item.videos.toString()) root.setOnClickListener { NavigationHelper.navigateChannel(root.context, item.url) } - val channelId = item.url!!.toID() - isSubscribed(channelId, binding) - } - } - - private fun isSubscribed(channelId: String, binding: ChannelRowBinding) { - // check whether the user subscribed to the channel - CoroutineScope(Dispatchers.Main).launch { - var isSubscribed = SubscriptionHelper.isSubscribed(channelId) - - // if subscribed change text to unsubscribe - if (isSubscribed == true) { - binding.searchSubButton.text = binding.root.context.getString(R.string.unsubscribe) - } - - // make sub button visible and set the on click listeners to (un)subscribe - if (isSubscribed == null) return@launch - binding.searchSubButton.visibility = View.VISIBLE - - binding.searchSubButton.setOnClickListener { - if (isSubscribed == false) { - SubscriptionHelper.subscribe(channelId) - binding.searchSubButton.text = - binding.root.context.getString(R.string.unsubscribe) - isSubscribed = true - } else { - SubscriptionHelper.unsubscribe(channelId) - binding.searchSubButton.text = - binding.root.context.getString(R.string.subscribe) - isSubscribed = false - } - } + binding.searchSubButton.setupSubscriptionButton(item.url?.toID(), item.name?.toID()) } } private fun bindPlaylist( - item: SearchItem, - binding: PlaylistSearchRowBinding + item: ContentItem, + binding: PlaylistsRowBinding ) { binding.apply { - ImageHelper.loadImage(item.thumbnail, searchThumbnail) - if (item.videos?.toInt() != -1) searchPlaylistNumber.text = item.videos.toString() - searchDescription.text = item.name - searchName.text = item.uploaderName - if (item.videos?.toInt() != -1) { - searchPlaylistVideos.text = - root.context.getString(R.string.videoCount, item.videos.toString()) - } + ImageHelper.loadImage(item.thumbnail, playlistThumbnail) + if (item.videos?.toInt() != -1) videoCount.text = item.videos.toString() + playlistTitle.text = item.name + playlistDescription.text = item.uploaderName root.setOnClickListener { - NavigationHelper.navigatePlaylist(root.context, item.url, false) + NavigationHelper.navigatePlaylist(root.context, item.url, PlaylistType.PUBLIC) } + deletePlaylist.visibility = View.GONE root.setOnLongClickListener { val playlistId = item.url!!.toID() - PlaylistOptionsBottomSheet(playlistId, false) - .show(childFragmentManager, PlaylistOptionsBottomSheet::class.java.name) + val playlistName = item.name!! + PlaylistOptionsBottomSheet(playlistId, playlistName, PlaylistType.PUBLIC) + .show((root.context as BaseActivity).supportFragmentManager, PlaylistOptionsBottomSheet::class.java.name) true } } diff --git a/app/src/main/java/com/github/libretube/ui/adapters/SearchSuggestionsAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/SearchSuggestionsAdapter.kt index 1abc3d2b3..d05f943e9 100644 --- a/app/src/main/java/com/github/libretube/ui/adapters/SearchSuggestionsAdapter.kt +++ b/app/src/main/java/com/github/libretube/ui/adapters/SearchSuggestionsAdapter.kt @@ -30,6 +30,9 @@ class SearchSuggestionsAdapter( root.setOnClickListener { searchView.setQuery(suggestion, true) } + arrow.setOnClickListener { + searchView.setQuery(suggestion, false) + } } } } diff --git a/app/src/main/java/com/github/libretube/ui/adapters/SubscriptionChannelAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/SubscriptionChannelAdapter.kt index ca792802d..91675f16f 100644 --- a/app/src/main/java/com/github/libretube/ui/adapters/SubscriptionChannelAdapter.kt +++ b/app/src/main/java/com/github/libretube/ui/adapters/SubscriptionChannelAdapter.kt @@ -3,16 +3,17 @@ package com.github.libretube.ui.adapters import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView -import com.github.libretube.R -import com.github.libretube.api.SubscriptionHelper +import com.github.libretube.api.obj.Subscription import com.github.libretube.databinding.ChannelSubscriptionRowBinding import com.github.libretube.extensions.toID +import com.github.libretube.ui.extensions.setupSubscriptionButton import com.github.libretube.ui.viewholders.SubscriptionChannelViewHolder import com.github.libretube.util.ImageHelper import com.github.libretube.util.NavigationHelper -class SubscriptionChannelAdapter(private val subscriptions: MutableList) : - RecyclerView.Adapter() { +class SubscriptionChannelAdapter( + private val subscriptions: MutableList +) : RecyclerView.Adapter() { override fun getItemCount(): Int { return subscriptions.size @@ -27,27 +28,20 @@ class SubscriptionChannelAdapter(private val subscriptions: MutableList, - private val childFragmentManager: FragmentManager, - private val showAllAtOne: Boolean = true -) : RecyclerView.Adapter() { - - var index = 10 - - override fun getItemCount(): Int { - return if (showAllAtOne) { - streamItems.size - } else if (index >= streamItems.size) { - streamItems.size - 1 - } else { - index - } - } - - fun updateItems() { - val oldSize = index - index += 10 - notifyItemRangeInserted(oldSize, index) - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SubscriptionViewHolder { - val layoutInflater = LayoutInflater.from(parent.context) - val binding = TrendingRowBinding.inflate(layoutInflater, parent, false) - return SubscriptionViewHolder(binding) - } - - @SuppressLint("SetTextI18n") - override fun onBindViewHolder(holder: SubscriptionViewHolder, position: Int) { - val trending = streamItems[position] - holder.binding.apply { - textViewTitle.text = trending.title - textViewChannel.text = - trending.uploaderName + " • " + - trending.views.formatShort() + " " + - getApplicationContext().resources.getString(R.string.views_placeholder) + - " • " + DateUtils.getRelativeTimeSpanString(trending.uploaded!!) - thumbnailDuration.setFormattedDuration(trending.duration!!) - channelImage.setOnClickListener { - NavigationHelper.navigateChannel(root.context, trending.uploaderUrl) - } - ImageHelper.loadImage(trending.thumbnail, thumbnail) - ImageHelper.loadImage(trending.uploaderAvatar, channelImage) - root.setOnClickListener { - NavigationHelper.navigateVideo(root.context, trending.url) - } - val videoId = trending.url!!.toID() - root.setOnLongClickListener { - VideoOptionsBottomSheet(videoId) - .show(childFragmentManager, VideoOptionsBottomSheet::class.java.name) - true - } - watchProgress.setWatchProgressLength(videoId, trending.duration!!) - } - } -} diff --git a/app/src/main/java/com/github/libretube/ui/adapters/VideosAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/VideosAdapter.kt new file mode 100644 index 000000000..55175357d --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/adapters/VideosAdapter.kt @@ -0,0 +1,195 @@ +package com.github.libretube.ui.adapters + +import android.annotation.SuppressLint +import android.content.Context +import android.text.format.DateUtils +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.LayoutManager +import com.github.libretube.R +import com.github.libretube.api.obj.StreamItem +import com.github.libretube.constants.PreferenceKeys +import com.github.libretube.databinding.TrendingRowBinding +import com.github.libretube.databinding.VideoRowBinding +import com.github.libretube.extensions.formatShort +import com.github.libretube.extensions.toDp +import com.github.libretube.extensions.toID +import com.github.libretube.ui.base.BaseActivity +import com.github.libretube.ui.extensions.setFormattedDuration +import com.github.libretube.ui.extensions.setWatchProgressLength +import com.github.libretube.ui.sheets.VideoOptionsBottomSheet +import com.github.libretube.ui.viewholders.VideosViewHolder +import com.github.libretube.util.ImageHelper +import com.github.libretube.util.NavigationHelper +import com.github.libretube.util.PreferenceHelper +import com.github.libretube.util.TextUtils + +class VideosAdapter( + private val streamItems: MutableList, + private val showAllAtOnce: Boolean = true, + private val forceMode: ForceMode = ForceMode.NONE +) : RecyclerView.Adapter() { + + var index = 10 + + override fun getItemCount(): Int { + return when { + showAllAtOnce -> streamItems.size + index >= streamItems.size -> streamItems.size - 1 + else -> index + } + } + + fun updateItems() { + val oldSize = index + index += 10 + notifyItemRangeInserted(oldSize, index) + } + + fun insertItems(newItems: List) { + val feedSize = streamItems.size + streamItems.addAll(newItems) + notifyItemRangeInserted(feedSize, newItems.size) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VideosViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + return when { + forceMode in listOf(ForceMode.TRENDING, ForceMode.RELATED, ForceMode.HOME) -> VideosViewHolder(TrendingRowBinding.inflate(layoutInflater, parent, false)) + forceMode == ForceMode.CHANNEL -> VideosViewHolder(VideoRowBinding.inflate(layoutInflater, parent, false)) + PreferenceHelper.getBoolean( + PreferenceKeys.ALTERNATIVE_VIDEOS_LAYOUT, + false + ) -> VideosViewHolder(VideoRowBinding.inflate(layoutInflater, parent, false)) + else -> VideosViewHolder(TrendingRowBinding.inflate(layoutInflater, parent, false)) + } + } + + @SuppressLint("SetTextI18n") + override fun onBindViewHolder(holder: VideosViewHolder, position: Int) { + val video = streamItems[position] + + // hide the item if there was an extractor error + if (video.title == null) { + holder.itemView.visibility = View.GONE + holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0) + return + } + + // Trending layout + holder.trendingRowBinding?.apply { + // set a fixed width for better visuals + val params = root.layoutParams + when (forceMode) { + ForceMode.RELATED -> params.width = (180).toDp(root.context.resources).toInt() + ForceMode.HOME -> params.width = (250).toDp(root.context.resources).toInt() + else -> {} + } + root.layoutParams = params + + textViewTitle.text = video.title + textViewChannel.text = + video.uploaderName + TextUtils.SEPARATOR + + video.views.formatShort() + " " + + root.context.getString(R.string.views_placeholder) + + TextUtils.SEPARATOR + video.uploaded?.let { DateUtils.getRelativeTimeSpanString(it) } + video.duration?.let { thumbnailDuration.setFormattedDuration(it) } + channelImage.setOnClickListener { + NavigationHelper.navigateChannel(root.context, video.uploaderUrl) + } + ImageHelper.loadImage(video.thumbnail, thumbnail) + ImageHelper.loadImage(video.uploaderAvatar, channelImage) + root.setOnClickListener { + NavigationHelper.navigateVideo(root.context, video.url) + } + val videoId = video.url?.toID() + val videoName = video.title + root.setOnLongClickListener { + if (videoId == null || videoName == null) return@setOnLongClickListener true + + VideoOptionsBottomSheet(videoId, videoName) + .show((root.context as BaseActivity).supportFragmentManager, VideoOptionsBottomSheet::class.java.name) + + true + } + if (videoId != null) { + watchProgress.setWatchProgressLength(videoId, video.duration ?: 0L) + } + } + + // Normal videos row layout + holder.videoRowBinding?.apply { + videoTitle.text = video.title + + videoInfo.text = + video.views.formatShort() + " " + + root.context.getString(R.string.views_placeholder) + + TextUtils.SEPARATOR + video.uploaded?.let { DateUtils.getRelativeTimeSpanString(it) } + + thumbnailDuration.text = + video.duration?.let { DateUtils.formatElapsedTime(it) } + + ImageHelper.loadImage(video.thumbnail, thumbnail) + + if (forceMode != ForceMode.CHANNEL) { + ImageHelper.loadImage(video.uploaderAvatar, channelImage) + channelName.text = video.uploaderName + + channelContainer.setOnClickListener { + NavigationHelper.navigateChannel(root.context, video.uploaderUrl) + } + } + + root.setOnClickListener { + NavigationHelper.navigateVideo(root.context, video.url) + } + + val videoId = video.url?.toID() + val videoName = video.title + root.setOnLongClickListener { + if (videoId == null || videoName == null) return@setOnLongClickListener true + VideoOptionsBottomSheet(videoId, videoName) + .show((root.context as BaseActivity).supportFragmentManager, VideoOptionsBottomSheet::class.java.name) + + true + } + + if (videoId != null) { + watchProgress.setWatchProgressLength(videoId, video.duration ?: 0L) + } + } + } + + companion object { + enum class ForceMode { + NONE, + TRENDING, + ROW, + CHANNEL, + RELATED, + HOME + } + + fun getLayout(context: Context): LayoutManager { + return if (PreferenceHelper.getBoolean( + PreferenceKeys.ALTERNATIVE_VIDEOS_LAYOUT, + false + ) + ) { + LinearLayoutManager(context) + } else { + GridLayoutManager( + context, + PreferenceHelper.getString( + PreferenceKeys.GRID_COLUMNS, + context.resources.getInteger(R.integer.grid_items).toString() + ).toInt() + ) + } + } + } +} diff --git a/app/src/main/java/com/github/libretube/ui/adapters/WatchHistoryAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/WatchHistoryAdapter.kt index d6287289b..efbb50faa 100644 --- a/app/src/main/java/com/github/libretube/ui/adapters/WatchHistoryAdapter.kt +++ b/app/src/main/java/com/github/libretube/ui/adapters/WatchHistoryAdapter.kt @@ -2,26 +2,28 @@ package com.github.libretube.ui.adapters import android.view.LayoutInflater import android.view.ViewGroup -import androidx.fragment.app.FragmentManager import androidx.recyclerview.widget.RecyclerView import com.github.libretube.databinding.WatchHistoryRowBinding -import com.github.libretube.db.DatabaseHelper +import com.github.libretube.db.DatabaseHolder import com.github.libretube.db.obj.WatchHistoryItem -import com.github.libretube.extensions.setFormattedDuration -import com.github.libretube.extensions.setWatchProgressLength +import com.github.libretube.extensions.query +import com.github.libretube.ui.base.BaseActivity +import com.github.libretube.ui.extensions.setFormattedDuration +import com.github.libretube.ui.extensions.setWatchProgressLength import com.github.libretube.ui.sheets.VideoOptionsBottomSheet import com.github.libretube.ui.viewholders.WatchHistoryViewHolder import com.github.libretube.util.ImageHelper import com.github.libretube.util.NavigationHelper class WatchHistoryAdapter( - private val watchHistory: MutableList, - private val childFragmentManager: FragmentManager + private val watchHistory: MutableList ) : RecyclerView.Adapter() { fun removeFromWatchHistory(position: Int) { - DatabaseHelper.removeFromWatchHistory(position) + query { + DatabaseHolder.Database.watchHistoryDao().delete(watchHistory[position]) + } watchHistory.removeAt(position) notifyItemRemoved(position) notifyItemRangeChanged(position, itemCount) @@ -55,8 +57,8 @@ class WatchHistoryAdapter( NavigationHelper.navigateVideo(root.context, video.videoId) } root.setOnLongClickListener { - VideoOptionsBottomSheet(video.videoId) - .show(childFragmentManager, VideoOptionsBottomSheet::class.java.name) + VideoOptionsBottomSheet(video.videoId, video.title!!) + .show((root.context as BaseActivity).supportFragmentManager, VideoOptionsBottomSheet::class.java.name) true } diff --git a/app/src/main/java/com/github/libretube/ui/dialogs/AddToPlaylistDialog.kt b/app/src/main/java/com/github/libretube/ui/dialogs/AddToPlaylistDialog.kt index 38d7269c4..1c052f2d1 100644 --- a/app/src/main/java/com/github/libretube/ui/dialogs/AddToPlaylistDialog.kt +++ b/app/src/main/java/com/github/libretube/ui/dialogs/AddToPlaylistDialog.kt @@ -1,10 +1,7 @@ package com.github.libretube.ui.dialogs import android.app.Dialog -import android.content.Context import android.os.Bundle -import android.os.Handler -import android.os.Looper import android.util.Log import android.widget.ArrayAdapter import android.widget.Toast @@ -13,36 +10,34 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import com.github.libretube.R -import com.github.libretube.api.RetrofitInstance -import com.github.libretube.api.obj.PlaylistId -import com.github.libretube.constants.IntentData +import com.github.libretube.api.PlaylistsHelper import com.github.libretube.databinding.DialogAddtoplaylistBinding import com.github.libretube.extensions.TAG -import com.github.libretube.models.PlaylistViewModel -import com.github.libretube.util.PreferenceHelper +import com.github.libretube.extensions.toastFromMainThread +import com.github.libretube.ui.models.PlaylistViewModel import com.github.libretube.util.ThemeHelper import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import retrofit2.HttpException -import java.io.IOException -class AddToPlaylistDialog : DialogFragment() { +class AddToPlaylistDialog( + private val videoId: String +) : DialogFragment() { private lateinit var binding: DialogAddtoplaylistBinding private val viewModel: PlaylistViewModel by activityViewModels() - private lateinit var videoId: String - private lateinit var token: String - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - videoId = arguments?.getString(IntentData.videoId)!! binding = DialogAddtoplaylistBinding.inflate(layoutInflater) binding.title.text = ThemeHelper.getStyledAppName(requireContext()) - token = PreferenceHelper.getToken() + binding.createPlaylist.setOnClickListener { + CreatePlaylistDialog { + fetchPlaylists() + }.show(childFragmentManager, null) + } - if (token != "") fetchPlaylists() + fetchPlaylists() return MaterialAlertDialogBuilder(requireContext()) .setView(binding.root) @@ -52,16 +47,11 @@ class AddToPlaylistDialog : DialogFragment() { private fun fetchPlaylists() { lifecycleScope.launchWhenCreated { val response = try { - RetrofitInstance.authApi.playlists(token) - } catch (e: IOException) { - println(e) - Log.e(TAG(), "IOException, you might not have internet connection") + PlaylistsHelper.getPlaylists() + } catch (e: Exception) { + Log.e(TAG(), e.toString()) Toast.makeText(context, R.string.unknown_error, Toast.LENGTH_SHORT).show() return@launchWhenCreated - } catch (e: HttpException) { - Log.e(TAG(), "HttpException, unexpected response") - Toast.makeText(context, R.string.server_error, Toast.LENGTH_SHORT).show() - return@launchWhenCreated } if (response.isNotEmpty()) { val names = response.map { it.name } @@ -75,8 +65,7 @@ class AddToPlaylistDialog : DialogFragment() { var selectionIndex = 0 response.forEachIndexed { index, playlist -> if (playlist.id == viewModel.lastSelectedPlaylistId) { - selectionIndex = - index + selectionIndex = index } } binding.playlistsSpinner.setSelection(selectionIndex) @@ -96,38 +85,19 @@ class AddToPlaylistDialog : DialogFragment() { private fun addToPlaylist(playlistId: String) { val appContext = context?.applicationContext ?: return CoroutineScope(Dispatchers.IO).launch { - val response = try { - RetrofitInstance.authApi.addToPlaylist( - token, - PlaylistId(playlistId, videoId) - ) - } catch (e: IOException) { - println(e) - Log.e(TAG(), "IOException, you might not have internet connection") - toastFromMainThread(appContext, R.string.unknown_error) - return@launch - } catch (e: HttpException) { - Log.e(TAG(), "HttpException, unexpected response") - toastFromMainThread(appContext, R.string.server_error) + val success = try { + PlaylistsHelper.addToPlaylist(playlistId, videoId) + } catch (e: Exception) { + Log.e(TAG(), e.toString()) + appContext.toastFromMainThread(R.string.unknown_error) return@launch } - toastFromMainThread( - appContext, - if (response.message == "ok") R.string.added_to_playlist else R.string.fail + appContext.toastFromMainThread( + if (success) R.string.added_to_playlist else R.string.fail ) } } - private fun toastFromMainThread(context: Context, stringId: Int) { - Handler(Looper.getMainLooper()).post { - Toast.makeText( - context, - stringId, - Toast.LENGTH_SHORT - ).show() - } - } - private fun Fragment?.runOnUiThread(action: () -> Unit) { this ?: return if (!isAdded) return // Fragment not attached to an Activity diff --git a/app/src/main/java/com/github/libretube/ui/dialogs/BackupDialog.kt b/app/src/main/java/com/github/libretube/ui/dialogs/BackupDialog.kt index 4723ce7ef..128cb3f36 100644 --- a/app/src/main/java/com/github/libretube/ui/dialogs/BackupDialog.kt +++ b/app/src/main/java/com/github/libretube/ui/dialogs/BackupDialog.kt @@ -2,9 +2,11 @@ package com.github.libretube.ui.dialogs import android.app.Dialog import android.os.Bundle +import androidx.annotation.StringRes import androidx.fragment.app.DialogFragment import com.github.libretube.R import com.github.libretube.db.DatabaseHolder.Companion.Database +import com.github.libretube.extensions.awaitQuery import com.github.libretube.obj.BackupFile import com.github.libretube.obj.PreferenceItem import com.github.libretube.util.PreferenceHelper @@ -13,21 +15,56 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder class BackupDialog( private val createBackupFile: (BackupFile) -> Unit ) : DialogFragment() { - private val backupFile = BackupFile() + sealed class BackupOption(@StringRes val name: Int, val onSelected: (BackupFile) -> Unit) { + object WatchHistory : BackupOption(R.string.watch_history, onSelected = { + it.watchHistory = Database.watchHistoryDao().getAll() + }) + object WatchPositions : BackupOption(R.string.watch_positions, onSelected = { + it.watchPositions = Database.watchPositionDao().getAll() + }) + + object SearchHistory : BackupOption(R.string.search_history, onSelected = { + it.searchHistory = Database.searchHistoryDao().getAll() + }) + + object LocalSubscriptions : BackupOption(R.string.local_subscriptions, onSelected = { + it.localSubscriptions = Database.localSubscriptionDao().getAll() + }) + + object CustomInstances : BackupOption(R.string.backup_customInstances, onSelected = { + it.customInstances = Database.customInstanceDao().getAll() + }) + + object PlaylistBookmarks : BackupOption(R.string.bookmarks, onSelected = { + it.playlistBookmarks = Database.playlistBookmarkDao().getAll() + }) + + object LocalPlaylists : BackupOption(R.string.local_playlists, onSelected = { + it.localPlaylists = Database.localPlaylistsDao().getAll() + }) + + object Preferences : BackupOption(R.string.preferences, onSelected = { + it.preferences = PreferenceHelper.settings.all.map { + PreferenceItem(it.key, it.value) + } + }) + } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val backupOptionNames = listOf( - R.string.watch_history, - R.string.watch_positions, - R.string.search_history, - R.string.local_subscriptions, - R.string.backup_customInstances, - R.string.preferences + val backupOptions = listOf( + BackupOption.WatchHistory, + BackupOption.WatchPositions, + BackupOption.SearchHistory, + BackupOption.LocalSubscriptions, + BackupOption.CustomInstances, + BackupOption.PlaylistBookmarks, + BackupOption.LocalPlaylists, + BackupOption.Preferences ) - val backupItems = backupOptionNames.map { context?.getString(it)!! }.toTypedArray() + val backupItems = backupOptions.map { context?.getString(it.name)!! }.toTypedArray() - val selected = BooleanArray(backupOptionNames.size) { false } + val selected = BooleanArray(backupOptions.size) { false } return MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.backup) @@ -36,39 +73,12 @@ class BackupDialog( } .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.backup) { _, _ -> - val thread = Thread { - if (selected[0]) { - backupFile.watchHistory = - Database.watchHistoryDao().getAll() - } - if (selected[1]) { - backupFile.watchPositions = - Database.watchPositionDao().getAll() - } - if (selected[2]) { - backupFile.searchHistory = - Database.searchHistoryDao().getAll() - } - if (selected[3]) { - backupFile.localSubscriptions = - Database.localSubscriptionDao().getAll() - } - if (selected[4]) { - backupFile.customInstances = - Database.customInstanceDao().getAll() - } - if (selected[5]) { - backupFile.preferences = PreferenceHelper.settings.all.map { - PreferenceItem( - it.key, - it.value - ) - } + val backupFile = BackupFile() + awaitQuery { + backupOptions.forEachIndexed { index, option -> + if (selected[index]) option.onSelected.invoke(backupFile) } } - thread.start() - thread.join() - createBackupFile(backupFile) } .create() diff --git a/app/src/main/java/com/github/libretube/ui/dialogs/CreatePlaylistDialog.kt b/app/src/main/java/com/github/libretube/ui/dialogs/CreatePlaylistDialog.kt index 087cfbf8c..253925485 100644 --- a/app/src/main/java/com/github/libretube/ui/dialogs/CreatePlaylistDialog.kt +++ b/app/src/main/java/com/github/libretube/ui/dialogs/CreatePlaylistDialog.kt @@ -2,23 +2,18 @@ package com.github.libretube.ui.dialogs import android.app.Dialog import android.os.Bundle -import android.util.Log import android.widget.Toast import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope import com.github.libretube.R -import com.github.libretube.api.RetrofitInstance +import com.github.libretube.api.PlaylistsHelper import com.github.libretube.databinding.DialogCreatePlaylistBinding -import com.github.libretube.extensions.TAG -import com.github.libretube.ui.fragments.LibraryFragment -import com.github.libretube.util.PreferenceHelper import com.github.libretube.util.ThemeHelper import com.google.android.material.dialog.MaterialAlertDialogBuilder -import retrofit2.HttpException -import java.io.IOException -class CreatePlaylistDialog : DialogFragment() { - private var token: String = "" +class CreatePlaylistDialog( + private val onSuccess: () -> Unit = {} +) : DialogFragment() { private lateinit var binding: DialogCreatePlaylistBinding override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { @@ -30,14 +25,17 @@ class CreatePlaylistDialog : DialogFragment() { dismiss() } - token = PreferenceHelper.getToken() - binding.createNewPlaylist.setOnClickListener { // avoid creating the same playlist multiple times by spamming the button binding.createNewPlaylist.setOnClickListener(null) val listName = binding.playlistName.text.toString() if (listName != "") { - createPlaylist(listName) + lifecycleScope.launchWhenCreated { + PlaylistsHelper.createPlaylist(listName, requireContext().applicationContext) { + onSuccess.invoke() + dismiss() + } + } } else { Toast.makeText(context, R.string.emptyPlaylistName, Toast.LENGTH_LONG).show() } @@ -47,38 +45,4 @@ class CreatePlaylistDialog : DialogFragment() { .setView(binding.root) .show() } - - private fun createPlaylist(name: String) { - lifecycleScope.launchWhenCreated { - val response = try { - RetrofitInstance.authApi.createPlaylist( - token, - com.github.libretube.api.obj.Playlists(name = name) - ) - } catch (e: IOException) { - println(e) - Log.e(TAG(), "IOException, you might not have internet connection") - Toast.makeText(context, R.string.unknown_error, Toast.LENGTH_SHORT).show() - return@launchWhenCreated - } catch (e: HttpException) { - Log.e(TAG(), "HttpException, unexpected response $e") - Toast.makeText(context, R.string.server_error, Toast.LENGTH_SHORT).show() - return@launchWhenCreated - } - if (response.playlistId != null) { - Toast.makeText(context, R.string.playlistCreated, Toast.LENGTH_SHORT).show() - } else { - Toast.makeText(context, getString(R.string.unknown_error), Toast.LENGTH_SHORT) - .show() - } - // refresh the playlists in the library - try { - val parent = parentFragment as LibraryFragment - parent.fetchPlaylists() - } catch (e: Exception) { - Log.e(TAG(), e.toString()) - } - dismiss() - } - } } diff --git a/app/src/main/java/com/github/libretube/ui/dialogs/DeletePlaylistDialog.kt b/app/src/main/java/com/github/libretube/ui/dialogs/DeletePlaylistDialog.kt new file mode 100644 index 000000000..cf7dae196 --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/dialogs/DeletePlaylistDialog.kt @@ -0,0 +1,65 @@ +package com.github.libretube.ui.dialogs + +import android.app.Dialog +import android.os.Bundle +import android.util.Log +import androidx.fragment.app.DialogFragment +import com.github.libretube.R +import com.github.libretube.api.RetrofitInstance +import com.github.libretube.api.obj.PlaylistId +import com.github.libretube.db.DatabaseHolder +import com.github.libretube.enums.PlaylistType +import com.github.libretube.extensions.TAG +import com.github.libretube.extensions.awaitQuery +import com.github.libretube.util.PreferenceHelper +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class DeletePlaylistDialog( + private val playlistId: String, + private val playlistType: PlaylistType, + private val onSuccess: () -> Unit = {} +) : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.deletePlaylist) + .setMessage(R.string.areYouSure) + .setPositiveButton(R.string.yes) { _, _ -> + PreferenceHelper.getToken() + deletePlaylist() + } + .setNegativeButton(R.string.cancel, null) + .show() + } + + private fun deletePlaylist() { + if (playlistType == PlaylistType.LOCAL) { + awaitQuery { + DatabaseHolder.Database.localPlaylistsDao().deletePlaylistById(playlistId) + DatabaseHolder.Database.localPlaylistsDao().deletePlaylistItemsByPlaylistId(playlistId) + } + return + } + + CoroutineScope(Dispatchers.IO).launch { + val response = try { + RetrofitInstance.authApi.deletePlaylist( + PreferenceHelper.getToken(), + PlaylistId(playlistId) + ) + } catch (e: Exception) { + Log.e(TAG(), e.toString()) + return@launch + } + try { + if (response.message == "ok") { + onSuccess.invoke() + } + } catch (e: Exception) { + Log.e(TAG(), e.toString()) + } + } + } +} diff --git a/app/src/main/java/com/github/libretube/ui/dialogs/NavBarOptionsDialog.kt b/app/src/main/java/com/github/libretube/ui/dialogs/NavBarOptionsDialog.kt index 0d7833f7f..391766a6d 100644 --- a/app/src/main/java/com/github/libretube/ui/dialogs/NavBarOptionsDialog.kt +++ b/app/src/main/java/com/github/libretube/ui/dialogs/NavBarOptionsDialog.kt @@ -62,7 +62,7 @@ class NavBarOptionsDialog : DialogFragment() { .setTitle(R.string.navigation_bar) .setView(binding.root) .setPositiveButton(R.string.okay) { _, _ -> - NavBarHelper.setNavBarItems(adapter.items) + NavBarHelper.setNavBarItems(adapter.items, requireContext()) RequireRestartDialog() .show(requireParentFragment().childFragmentManager, null) } diff --git a/app/src/main/java/com/github/libretube/ui/dialogs/ShareDialog.kt b/app/src/main/java/com/github/libretube/ui/dialogs/ShareDialog.kt index ba135be6e..da10fe705 100644 --- a/app/src/main/java/com/github/libretube/ui/dialogs/ShareDialog.kt +++ b/app/src/main/java/com/github/libretube/ui/dialogs/ShareDialog.kt @@ -8,18 +8,19 @@ import androidx.fragment.app.DialogFragment import com.github.libretube.R import com.github.libretube.constants.PIPED_FRONTEND_URL import com.github.libretube.constants.PreferenceKeys -import com.github.libretube.constants.ShareObjectType import com.github.libretube.constants.YOUTUBE_FRONTEND_URL import com.github.libretube.databinding.DialogShareBinding import com.github.libretube.db.DatabaseHolder.Companion.Database +import com.github.libretube.enums.ShareObjectType import com.github.libretube.extensions.awaitQuery +import com.github.libretube.obj.ShareData import com.github.libretube.util.PreferenceHelper import com.google.android.material.dialog.MaterialAlertDialogBuilder class ShareDialog( private val id: String, - private val shareObjectType: Int, - private val position: Long? = null + private val shareObjectType: ShareObjectType, + private val shareData: ShareData ) : DialogFragment() { private var binding: DialogShareBinding? = null @@ -29,11 +30,11 @@ class ShareDialog( getString(R.string.youtube) ) val instanceUrl = getCustomInstanceFrontendUrl() - + val shareableTitle = getShareableTitle(shareData) // add instanceUrl option if custom instance frontend url available if (instanceUrl != "") shareOptions += getString(R.string.instance) - if (shareObjectType == ShareObjectType.VIDEO && position != null) { + if (shareObjectType == ShareObjectType.VIDEO) { setupTimeStampBinding() } @@ -55,7 +56,7 @@ class ShareDialog( } var url = "$host$path" - if (shareObjectType == ShareObjectType.VIDEO && position != null && binding!!.timeCodeSwitch.isChecked) { + if (shareObjectType == ShareObjectType.VIDEO && binding!!.timeCodeSwitch.isChecked) { url += "&t=${binding!!.timeStamp.text}" } @@ -63,6 +64,7 @@ class ShareDialog( intent.apply { action = Intent.ACTION_SEND putExtra(Intent.EXTRA_TEXT, url) + putExtra(Intent.EXTRA_SUBJECT, shareableTitle) type = "text/plain" } context?.startActivity( @@ -81,8 +83,9 @@ class ShareDialog( ) binding!!.timeCodeSwitch.setOnCheckedChangeListener { _, isChecked -> binding!!.timeStampLayout.visibility = if (isChecked) View.VISIBLE else View.GONE + PreferenceHelper.putBoolean(PreferenceKeys.SHARE_WITH_TIME_CODE, isChecked) } - binding!!.timeStamp.setText(position.toString()) + binding!!.timeStamp.setText((shareData.currentPosition ?: 0L).toString()) if (binding!!.timeCodeSwitch.isChecked) binding!!.timeStampLayout.visibility = View.VISIBLE } @@ -104,4 +107,18 @@ class ShareDialog( } return "" } + private fun getShareableTitle(shareData: ShareData): String { + shareData.apply { + currentChannel?.let { + return it + } + currentVideo?.let { + return it + } + currentPlaylist?.let { + return it + } + } + return "" + } } diff --git a/app/src/main/java/com/github/libretube/extensions/SetFormattedDuration.kt b/app/src/main/java/com/github/libretube/ui/extensions/SetFormattedDuration.kt similarity index 90% rename from app/src/main/java/com/github/libretube/extensions/SetFormattedDuration.kt rename to app/src/main/java/com/github/libretube/ui/extensions/SetFormattedDuration.kt index 1bc173c7e..a57e70b68 100644 --- a/app/src/main/java/com/github/libretube/extensions/SetFormattedDuration.kt +++ b/app/src/main/java/com/github/libretube/ui/extensions/SetFormattedDuration.kt @@ -1,4 +1,4 @@ -package com.github.libretube.extensions +package com.github.libretube.ui.extensions import android.text.format.DateUtils import android.widget.TextView diff --git a/app/src/main/java/com/github/libretube/extensions/SetWatchProgressLength.kt b/app/src/main/java/com/github/libretube/ui/extensions/SetWatchProgressLength.kt similarity index 92% rename from app/src/main/java/com/github/libretube/extensions/SetWatchProgressLength.kt rename to app/src/main/java/com/github/libretube/ui/extensions/SetWatchProgressLength.kt index 21fa010eb..fd9b3436d 100644 --- a/app/src/main/java/com/github/libretube/extensions/SetWatchProgressLength.kt +++ b/app/src/main/java/com/github/libretube/ui/extensions/SetWatchProgressLength.kt @@ -1,9 +1,10 @@ -package com.github.libretube.extensions +package com.github.libretube.ui.extensions import android.view.View import android.view.ViewTreeObserver import android.widget.LinearLayout import com.github.libretube.db.DatabaseHolder.Companion.Database +import com.github.libretube.extensions.awaitQuery /** * shows the already watched time under the video diff --git a/app/src/main/java/com/github/libretube/ui/extensions/SetupNotificationBell.kt b/app/src/main/java/com/github/libretube/ui/extensions/SetupNotificationBell.kt new file mode 100644 index 000000000..8f36e82ab --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/extensions/SetupNotificationBell.kt @@ -0,0 +1,18 @@ +package com.github.libretube.ui.extensions + +import android.util.Log +import com.github.libretube.R +import com.github.libretube.util.PreferenceHelper +import com.google.android.material.button.MaterialButton + +fun MaterialButton.setupNotificationBell(channelId: String) { + var isIgnorable = PreferenceHelper.isChannelNotificationIgnorable(channelId) + Log.e(channelId, isIgnorable.toString()) + setIconResource(if (isIgnorable) R.drawable.ic_bell else R.drawable.ic_notification) + + setOnClickListener { + isIgnorable = !isIgnorable + PreferenceHelper.toggleIgnorableNotificationChannel(channelId) + setIconResource(if (isIgnorable) R.drawable.ic_bell else R.drawable.ic_notification) + } +} diff --git a/app/src/main/java/com/github/libretube/ui/extensions/SetupSubscriptionButton.kt b/app/src/main/java/com/github/libretube/ui/extensions/SetupSubscriptionButton.kt new file mode 100644 index 000000000..79b8afd2d --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/extensions/SetupSubscriptionButton.kt @@ -0,0 +1,51 @@ +package com.github.libretube.ui.extensions + +import android.view.View +import android.widget.TextView +import com.github.libretube.R +import com.github.libretube.api.SubscriptionHelper +import com.google.android.material.button.MaterialButton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +fun TextView.setupSubscriptionButton( + channelId: String?, + channelName: String?, + notificationBell: MaterialButton? = null, + isSubscribed: Boolean? = null +) { + if (channelId == null) return + + var subscribed: Boolean? = false + + CoroutineScope(Dispatchers.IO).launch { + subscribed = isSubscribed ?: SubscriptionHelper.isSubscribed(channelId) + + withContext(Dispatchers.Main) { + if (subscribed == true) { + this@setupSubscriptionButton.text = context.getString(R.string.unsubscribe) + } else { + notificationBell?.visibility = View.GONE + } + this@setupSubscriptionButton.visibility = View.VISIBLE + } + } + + notificationBell?.setupNotificationBell(channelId) + this.setOnClickListener { + if (subscribed == true) { + SubscriptionHelper.handleUnsubscribe(context, channelId, channelName) { + this.text = context.getString(R.string.subscribe) + notificationBell?.visibility = View.GONE + subscribed = false + } + } else { + SubscriptionHelper.subscribe(channelId) + this.text = context.getString(R.string.unsubscribe) + notificationBell?.visibility = View.VISIBLE + subscribed = true + } + } +} diff --git a/app/src/main/java/com/github/libretube/ui/extensions/WithMaxSize.kt b/app/src/main/java/com/github/libretube/ui/extensions/WithMaxSize.kt new file mode 100644 index 000000000..4eec26c62 --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/extensions/WithMaxSize.kt @@ -0,0 +1,5 @@ +package com.github.libretube.ui.extensions + +fun List.withMaxSize(maxSize: Int): List { + return this.filterIndexed { index, _ -> index < maxSize } +} diff --git a/app/src/main/java/com/github/libretube/ui/fragments/BookmarksFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/BookmarksFragment.kt new file mode 100644 index 000000000..23f0e9335 --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/fragments/BookmarksFragment.kt @@ -0,0 +1,41 @@ +package com.github.libretube.ui.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.GridLayoutManager +import com.github.libretube.databinding.FragmentBookmarksBinding +import com.github.libretube.db.DatabaseHolder.Companion.Database +import com.github.libretube.extensions.awaitQuery +import com.github.libretube.ui.adapters.PlaylistBookmarkAdapter +import com.github.libretube.ui.base.BaseFragment + +class BookmarksFragment : BaseFragment() { + private lateinit var binding: FragmentBookmarksBinding + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentBookmarksBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val bookmarks = awaitQuery { + Database.playlistBookmarkDao().getAll() + } + + if (bookmarks.isEmpty()) return + + binding.bookmarksRV.layoutManager = GridLayoutManager(context, 2) + binding.bookmarksRV.adapter = PlaylistBookmarkAdapter(bookmarks) + + binding.bookmarksRV.visibility = View.VISIBLE + binding.emptyBookmarks.visibility = View.GONE + } +} diff --git a/app/src/main/java/com/github/libretube/ui/fragments/ChannelFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/ChannelFragment.kt index 4f9584f2e..fdf17ffbc 100644 --- a/app/src/main/java/com/github/libretube/ui/fragments/ChannelFragment.kt +++ b/app/src/main/java/com/github/libretube/ui/fragments/ChannelFragment.kt @@ -6,21 +6,30 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.TextView +import androidx.core.view.children import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import com.github.libretube.R import com.github.libretube.api.RetrofitInstance import com.github.libretube.api.SubscriptionHelper +import com.github.libretube.api.obj.ChannelTab import com.github.libretube.constants.IntentData -import com.github.libretube.constants.ShareObjectType import com.github.libretube.databinding.FragmentChannelBinding +import com.github.libretube.enums.ShareObjectType import com.github.libretube.extensions.TAG import com.github.libretube.extensions.formatShort import com.github.libretube.extensions.toID -import com.github.libretube.ui.adapters.ChannelAdapter +import com.github.libretube.obj.ChannelTabs +import com.github.libretube.obj.ShareData +import com.github.libretube.ui.adapters.SearchAdapter +import com.github.libretube.ui.adapters.VideosAdapter import com.github.libretube.ui.base.BaseFragment import com.github.libretube.ui.dialogs.ShareDialog +import com.github.libretube.ui.extensions.setupSubscriptionButton import com.github.libretube.util.ImageHelper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import retrofit2.HttpException import java.io.IOException @@ -29,12 +38,22 @@ class ChannelFragment : BaseFragment() { private var channelId: String? = null private var channelName: String? = null - private var nextPage: String? = null - private var channelAdapter: ChannelAdapter? = null + private var channelAdapter: VideosAdapter? = null private var isLoading = true private var isSubscribed: Boolean? = false + private var onScrollEnd: () -> Unit = {} + + private val scope = CoroutineScope(Dispatchers.IO) + + val possibleTabs = listOf( + ChannelTabs.Channels, + ChannelTabs.Playlists, + ChannelTabs.Livestreams, + ChannelTabs.Shorts + ) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) arguments?.let { @@ -63,7 +82,9 @@ class ChannelFragment : BaseFragment() { binding.channelRefresh.isRefreshing = true fetchChannel() } + refreshChannel() + binding.channelRefresh.setOnRefreshListener { refreshChannel() } @@ -73,11 +94,10 @@ class ChannelFragment : BaseFragment() { if (binding.channelScrollView.getChildAt(0).bottom == (binding.channelScrollView.height + binding.channelScrollView.scrollY) ) { - // scroll view is at bottom - if (nextPage != null && !isLoading) { - isLoading = true - binding.channelRefresh.isRefreshing = true - fetchChannelNextPage() + try { + onScrollEnd.invoke() + } catch (e: Exception) { + Log.e("tabs failed", e.toString()) } } } @@ -102,30 +122,26 @@ class ChannelFragment : BaseFragment() { } // needed if the channel gets loaded by the ID channelId = response.id + channelName = response.name + val shareData = ShareData(currentChannel = response.name) + + onScrollEnd = { + fetchChannelNextPage() + } // fetch and update the subscription status isSubscribed = SubscriptionHelper.isSubscribed(channelId!!) if (isSubscribed == null) return@launchWhenCreated runOnUiThread { - if (isSubscribed == true) { - binding.channelSubscribe.text = getString(R.string.unsubscribe) - } - - binding.channelSubscribe.setOnClickListener { - binding.channelSubscribe.text = if (isSubscribed == true) { - SubscriptionHelper.unsubscribe(channelId!!) - isSubscribed = false - getString(R.string.subscribe) - } else { - SubscriptionHelper.subscribe(channelId!!) - isSubscribed = true - getString(R.string.unsubscribe) - } - } + binding.channelSubscribe.setupSubscriptionButton(channelId, channelName, binding.notificationBell) binding.channelShare.setOnClickListener { - val shareDialog = ShareDialog(response.id!!.toID(), ShareObjectType.CHANNEL) + val shareDialog = ShareDialog( + response.id!!.toID(), + ShareObjectType.CHANNEL, + shareData + ) shareDialog.show(childFragmentManager, ShareDialog::class.java.name) } } @@ -165,23 +181,82 @@ class ChannelFragment : BaseFragment() { ImageHelper.loadImage(response.avatarUrl, binding.channelImage) // recyclerview of the videos by the channel - channelAdapter = ChannelAdapter( - response.relatedStreams!!.toMutableList(), - childFragmentManager + channelAdapter = VideosAdapter( + response.relatedStreams.orEmpty().toMutableList(), + forceMode = VideosAdapter.Companion.ForceMode.CHANNEL ) binding.channelRecView.adapter = channelAdapter } + + response.tabs?.let { setupTabs(it) } + } + } + + private fun setupTabs(tabs: List) { + binding.tabChips.children.forEach { chip -> + val resourceTab = possibleTabs.firstOrNull { it.chipId == chip.id } + resourceTab?.let { resTab -> + if (tabs.any { it.name == resTab.identifierName }) chip.visibility = View.VISIBLE + } + } + + binding.tabChips.setOnCheckedStateChangeListener { _, _ -> + when (binding.tabChips.checkedChipId) { + binding.videos.id -> { + binding.channelRecView.adapter = channelAdapter + onScrollEnd = { + fetchChannelNextPage() + } + } + else -> { + possibleTabs.first { binding.tabChips.checkedChipId == it.chipId }.let { + val tab = tabs.first { tab -> tab.name == it.identifierName } + loadTab(tab) + } + } + } + } + } + + private fun loadTab(tab: ChannelTab) { + scope.launch { + tab.data ?: return@launch + val response = try { + RetrofitInstance.api.getChannelTab(tab.data) + } catch (e: Exception) { + return@launch + } + + val adapter = SearchAdapter( + response.content.toMutableList() + ) + + runOnUiThread { + binding.channelRecView.adapter = adapter + } + + var tabNextPage = response.nextpage + onScrollEnd = { + tabNextPage?.let { + fetchTabNextPage(it, tab, adapter) { nextPage -> + tabNextPage = nextPage + } + } + } } } private fun fetchChannelNextPage() { fun run() { + if (nextPage == null || isLoading) return + isLoading = true + binding.channelRefresh.isRefreshing = true + lifecycleScope.launchWhenCreated { val response = try { RetrofitInstance.api.getChannelNextPage(channelId!!, nextPage!!) } catch (e: IOException) { binding.channelRefresh.isRefreshing = false - println(e) Log.e(TAG(), "IOException, you might not have internet connection") return@launchWhenCreated } catch (e: HttpException) { @@ -190,11 +265,33 @@ class ChannelFragment : BaseFragment() { return@launchWhenCreated } nextPage = response.nextpage - channelAdapter?.updateItems(response.relatedStreams!!) + channelAdapter?.insertItems(response.relatedStreams.orEmpty()) isLoading = false binding.channelRefresh.isRefreshing = false } } run() } + + private fun fetchTabNextPage( + nextPage: String, + tab: ChannelTab, + adapter: SearchAdapter, + onNewNextPage: (String?) -> Unit + ) { + scope.launch { + val newContent = try { + RetrofitInstance.api.getChannelTab(tab.data ?: "", nextPage) + } catch (e: Exception) { + e.printStackTrace() + null + } + onNewNextPage.invoke(newContent?.nextpage) + runOnUiThread { + newContent?.content?.let { + adapter.updateItems(it) + } + } + } + } } diff --git a/app/src/main/java/com/github/libretube/ui/fragments/HomeFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/HomeFragment.kt index c5a7566cf..a6d97fec4 100644 --- a/app/src/main/java/com/github/libretube/ui/fragments/HomeFragment.kt +++ b/app/src/main/java/com/github/libretube/ui/fragments/HomeFragment.kt @@ -1,39 +1,35 @@ package com.github.libretube.ui.fragments -import android.content.Intent import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.Toast import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import com.github.libretube.R +import com.github.libretube.api.PlaylistsHelper import com.github.libretube.api.RetrofitInstance -import com.github.libretube.constants.PreferenceKeys +import com.github.libretube.api.SubscriptionHelper import com.github.libretube.databinding.FragmentHomeBinding -import com.github.libretube.extensions.TAG -import com.github.libretube.ui.activities.SettingsActivity -import com.github.libretube.ui.adapters.ChannelAdapter -import com.github.libretube.ui.adapters.TrendingAdapter +import com.github.libretube.db.DatabaseHolder +import com.github.libretube.extensions.awaitQuery +import com.github.libretube.extensions.toastFromMainThread +import com.github.libretube.ui.adapters.PlaylistBookmarkAdapter +import com.github.libretube.ui.adapters.PlaylistsAdapter +import com.github.libretube.ui.adapters.VideosAdapter import com.github.libretube.ui.base.BaseFragment +import com.github.libretube.ui.extensions.withMaxSize import com.github.libretube.util.LocaleHelper import com.github.libretube.util.PreferenceHelper -import com.google.android.material.snackbar.Snackbar -import retrofit2.HttpException -import java.io.IOException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch class HomeFragment : BaseFragment() { private lateinit var binding: FragmentHomeBinding - private lateinit var region: String - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - arguments?.let { - } - } override fun onCreateView( inflater: LayoutInflater, @@ -46,89 +42,118 @@ class HomeFragment : BaseFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - val regionPref = PreferenceHelper.getString(PreferenceKeys.REGION, "sys") - // get the system default country if auto region selected - region = if (regionPref == "sys") { - LocaleHelper - .getDetectedCountry(requireContext(), "UK") - .uppercase() - } else { - regionPref + binding.featuredTV.setOnClickListener { + findNavController().navigate(R.id.subscriptionsFragment) } - fetchTrending() - binding.homeRefresh.isEnabled = true - binding.homeRefresh.setOnRefreshListener { - fetchTrending() + binding.trendingTV.setOnClickListener { + findNavController().navigate(R.id.trendsFragment) + } + + binding.playlistsTV.setOnClickListener { + findNavController().navigate(R.id.libraryFragment) + } + + binding.bookmarksTV.setOnClickListener { + findNavController().navigate(R.id.bookmarksFragment) + } + + binding.refresh.setOnRefreshListener { + binding.refresh.isRefreshing = true + lifecycleScope.launch(Dispatchers.IO) { + fetchHome() + } + } + + lifecycleScope.launch(Dispatchers.IO) { + fetchHome() } } - private fun fetchTrending() { - lifecycleScope.launchWhenCreated { - val response = try { - RetrofitInstance.api.getTrending(region) - } catch (e: IOException) { - println(e) - Log.e(TAG(), "IOException, you might not have internet connection") - Toast.makeText(context, R.string.unknown_error, Toast.LENGTH_SHORT).show() - return@launchWhenCreated - } catch (e: HttpException) { - Log.e(TAG(), "HttpException, unexpected response") - Toast.makeText(context, R.string.server_error, Toast.LENGTH_SHORT).show() - return@launchWhenCreated - } finally { - binding.homeRefresh.isRefreshing = false - } + private suspend fun fetchHome() { + val token = PreferenceHelper.getToken() + runOrError { + val feed = SubscriptionHelper.getFeed().withMaxSize(20) + if (feed.isEmpty()) return@runOrError runOnUiThread { - binding.progressBar.visibility = View.GONE - - // show a [SnackBar] if there are no trending videos available - if (response.isEmpty()) { - Snackbar.make( - binding.root, - R.string.change_region, - Snackbar.LENGTH_LONG - ) - .setAction( - R.string.settings - ) { - startActivity( - Intent( - context, - SettingsActivity::class.java - ) - ) - } - .show() - return@runOnUiThread - } - - if ( - PreferenceHelper.getBoolean( - PreferenceKeys.ALTERNATIVE_TRENDING_LAYOUT, - false - ) - ) { - binding.recview.layoutManager = LinearLayoutManager(context) - - binding.recview.adapter = ChannelAdapter( - response.toMutableList(), - childFragmentManager, - true - ) - } else { - binding.recview.layoutManager = GridLayoutManager( - context, - PreferenceHelper.getString( - PreferenceKeys.GRID_COLUMNS, - resources.getInteger(R.integer.grid_items).toString() - ).toInt() - ) - - binding.recview.adapter = TrendingAdapter(response, childFragmentManager) - } + makeVisible(binding.featuredRV, binding.featuredTV) + binding.featuredRV.layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) + binding.featuredRV.adapter = VideosAdapter( + feed.toMutableList(), + forceMode = VideosAdapter.Companion.ForceMode.HOME + ) } } + + runOrError { + val trending = RetrofitInstance.api.getTrending( + LocaleHelper.getTrendingRegion(requireContext()) + ).withMaxSize(10) + if (trending.isEmpty()) return@runOrError + runOnUiThread { + makeVisible(binding.trendingRV, binding.trendingTV) + binding.trendingRV.layoutManager = GridLayoutManager(context, 2) + binding.trendingRV.adapter = VideosAdapter( + trending.toMutableList(), + forceMode = VideosAdapter.Companion.ForceMode.TRENDING + ) + } + } + + runOrError { + val playlists = PlaylistsHelper.getPlaylists().withMaxSize(20) + if (playlists.isEmpty()) return@runOrError + runOnUiThread { + makeVisible(binding.playlistsRV, binding.playlistsTV) + binding.playlistsRV.layoutManager = LinearLayoutManager(context) + binding.playlistsRV.adapter = PlaylistsAdapter(playlists.toMutableList(), PlaylistsHelper.getPrivateType()) + binding.playlistsRV.adapter?.registerAdapterDataObserver(object : + RecyclerView.AdapterDataObserver() { + override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) { + super.onItemRangeRemoved(positionStart, itemCount) + if (itemCount == 0) { + binding.playlistsRV.visibility = View.GONE + binding.playlistsTV.visibility = View.GONE + } + } + }) + } + } + + runOrError { + val bookmarkedPlaylists = awaitQuery { + DatabaseHolder.Database.playlistBookmarkDao().getAll() + } + if (bookmarkedPlaylists.isEmpty()) return@runOrError + runOnUiThread { + makeVisible(binding.bookmarksTV, binding.bookmarksRV) + binding.bookmarksRV.layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) + binding.bookmarksRV.adapter = PlaylistBookmarkAdapter( + bookmarkedPlaylists, + PlaylistBookmarkAdapter.Companion.BookmarkMode.HOME + ) + } + } + } + + private fun runOrError(action: suspend () -> Unit) { + lifecycleScope.launch(Dispatchers.IO) { + try { + action.invoke() + } catch (e: Exception) { + e.localizedMessage?.let { context?.toastFromMainThread(it) } + Log.e("fetching home tab", e.toString()) + } + } + } + + private fun makeVisible(vararg views: View) { + views.forEach { + it.visibility = View.VISIBLE + } + binding.progress.visibility = View.GONE + binding.scroll.visibility = View.VISIBLE + binding.refresh.isRefreshing = false } } diff --git a/app/src/main/java/com/github/libretube/ui/fragments/LibraryFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/LibraryFragment.kt index 1c6cabbdd..99051b126 100644 --- a/app/src/main/java/com/github/libretube/ui/fragments/LibraryFragment.kt +++ b/app/src/main/java/com/github/libretube/ui/fragments/LibraryFragment.kt @@ -12,18 +12,16 @@ import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.github.libretube.R -import com.github.libretube.api.RetrofitInstance +import com.github.libretube.api.PlaylistsHelper import com.github.libretube.constants.PreferenceKeys import com.github.libretube.databinding.FragmentLibraryBinding import com.github.libretube.extensions.TAG import com.github.libretube.extensions.toDp -import com.github.libretube.models.PlayerViewModel import com.github.libretube.ui.adapters.PlaylistsAdapter import com.github.libretube.ui.base.BaseFragment import com.github.libretube.ui.dialogs.CreatePlaylistDialog +import com.github.libretube.ui.models.PlayerViewModel import com.github.libretube.util.PreferenceHelper -import retrofit2.HttpException -import java.io.IOException class LibraryFragment : BaseFragment() { @@ -55,35 +53,31 @@ class LibraryFragment : BaseFragment() { val watchHistoryEnabled = PreferenceHelper.getBoolean(PreferenceKeys.WATCH_HISTORY_TOGGLE, true) if (!watchHistoryEnabled) { - binding.showWatchHistory.visibility = View.GONE + binding.watchHistory.visibility = View.GONE } else { - binding.showWatchHistory.setOnClickListener { + binding.watchHistory.setOnClickListener { findNavController().navigate(R.id.watchHistoryFragment) } } + binding.bookmarks.setOnClickListener { + findNavController().navigate(R.id.bookmarksFragment) + } + binding.downloads.setOnClickListener { findNavController().navigate(R.id.downloadsFragment) } - if (token != "") { - binding.boogh.setImageResource(R.drawable.ic_list) - binding.textLike.text = getString(R.string.emptyList) + fetchPlaylists() - binding.loginOrRegister.visibility = View.GONE + binding.playlistRefresh.isEnabled = true + binding.playlistRefresh.setOnRefreshListener { fetchPlaylists() - - binding.playlistRefresh.isEnabled = true - binding.playlistRefresh.setOnRefreshListener { + } + binding.createPlaylist.setOnClickListener { + CreatePlaylistDialog { fetchPlaylists() - } - binding.createPlaylist.setOnClickListener { - val newFragment = CreatePlaylistDialog() - newFragment.show(childFragmentManager, CreatePlaylistDialog::class.java.name) - } - } else { - binding.playlistRefresh.isEnabled = false - binding.createPlaylist.visibility = View.GONE + }.show(childFragmentManager, CreatePlaylistDialog::class.java.name) } } @@ -95,26 +89,19 @@ class LibraryFragment : BaseFragment() { binding.createPlaylist.layoutParams = layoutParams } - fun fetchPlaylists() { + private fun fetchPlaylists() { binding.playlistRefresh.isRefreshing = true lifecycleScope.launchWhenCreated { var playlists = try { - RetrofitInstance.authApi.playlists(token) - } catch (e: IOException) { - println(e) - Log.e(TAG(), "IOException, you might not have internet connection") + PlaylistsHelper.getPlaylists() + } catch (e: Exception) { + Log.e(TAG(), e.toString()) Toast.makeText(context, R.string.unknown_error, Toast.LENGTH_SHORT).show() return@launchWhenCreated - } catch (e: HttpException) { - Log.e(TAG(), "HttpException, unexpected response") - Toast.makeText(context, R.string.server_error, Toast.LENGTH_SHORT).show() - return@launchWhenCreated } finally { binding.playlistRefresh.isRefreshing = false } if (playlists.isNotEmpty()) { - binding.loginOrRegister.visibility = View.GONE - playlists = when ( PreferenceHelper.getString( PreferenceKeys.PLAYLISTS_ORDER, @@ -130,17 +117,14 @@ class LibraryFragment : BaseFragment() { val playlistsAdapter = PlaylistsAdapter( playlists.toMutableList(), - childFragmentManager, - requireActivity() + PlaylistsHelper.getPrivateType() ) // listen for playlists to become deleted playlistsAdapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { override fun onChanged() { - if (playlistsAdapter.itemCount == 0) { - binding.loginOrRegister.visibility = View.VISIBLE - } + binding.nothingHere.visibility = if (playlistsAdapter.itemCount == 0) View.VISIBLE else View.GONE super.onChanged() } }) @@ -148,7 +132,7 @@ class LibraryFragment : BaseFragment() { binding.playlistRecView.adapter = playlistsAdapter } else { runOnUiThread { - binding.loginOrRegister.visibility = View.VISIBLE + binding.nothingHere.visibility = View.VISIBLE } } } diff --git a/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt index b54c098b7..50fde12b9 100644 --- a/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt +++ b/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt @@ -18,6 +18,7 @@ import android.os.Looper import android.os.PowerManager import android.text.Html import android.text.format.DateUtils +import android.util.Base64 import android.util.Log import android.view.LayoutInflater import android.view.MotionEvent @@ -27,71 +28,79 @@ import android.widget.Toast import androidx.constraintlayout.motion.widget.MotionLayout import androidx.core.net.toUri import androidx.core.os.bundleOf +import androidx.core.view.isEmpty import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import com.fasterxml.jackson.databind.ObjectMapper import com.github.libretube.R import com.github.libretube.api.CronetHelper import com.github.libretube.api.RetrofitInstance -import com.github.libretube.api.SubscriptionHelper +import com.github.libretube.api.obj.ChapterSegment +import com.github.libretube.api.obj.PipedStream +import com.github.libretube.api.obj.Segment +import com.github.libretube.api.obj.SegmentData +import com.github.libretube.api.obj.StreamItem +import com.github.libretube.api.obj.Streams import com.github.libretube.constants.IntentData import com.github.libretube.constants.PreferenceKeys -import com.github.libretube.constants.ShareObjectType import com.github.libretube.databinding.DoubleTapOverlayBinding import com.github.libretube.databinding.ExoStyledPlayerControlViewBinding import com.github.libretube.databinding.FragmentPlayerBinding import com.github.libretube.db.DatabaseHelper import com.github.libretube.db.DatabaseHolder.Companion.Database +import com.github.libretube.db.obj.WatchPosition +import com.github.libretube.enums.ShareObjectType import com.github.libretube.extensions.TAG import com.github.libretube.extensions.awaitQuery import com.github.libretube.extensions.formatShort import com.github.libretube.extensions.hideKeyboard import com.github.libretube.extensions.query import com.github.libretube.extensions.toID -import com.github.libretube.models.PlayerViewModel -import com.github.libretube.models.interfaces.PlayerOptionsInterface +import com.github.libretube.extensions.toStreamItem +import com.github.libretube.obj.ShareData +import com.github.libretube.obj.VideoResolution import com.github.libretube.services.BackgroundMode import com.github.libretube.services.DownloadService import com.github.libretube.ui.activities.MainActivity import com.github.libretube.ui.adapters.ChaptersAdapter import com.github.libretube.ui.adapters.CommentsAdapter -import com.github.libretube.ui.adapters.TrendingAdapter +import com.github.libretube.ui.adapters.VideosAdapter import com.github.libretube.ui.base.BaseFragment import com.github.libretube.ui.dialogs.AddToPlaylistDialog import com.github.libretube.ui.dialogs.DownloadDialog import com.github.libretube.ui.dialogs.ShareDialog -import com.github.libretube.ui.views.BottomSheet -import com.github.libretube.util.AutoPlayHelper +import com.github.libretube.ui.extensions.setupSubscriptionButton +import com.github.libretube.ui.interfaces.OnlinePlayerOptions +import com.github.libretube.ui.models.PlayerViewModel +import com.github.libretube.ui.sheets.BaseBottomSheet +import com.github.libretube.ui.sheets.PlayingQueueSheet import com.github.libretube.util.BackgroundHelper +import com.github.libretube.util.DashHelper import com.github.libretube.util.ImageHelper import com.github.libretube.util.NowPlayingNotification import com.github.libretube.util.PlayerHelper import com.github.libretube.util.PlayingQueue import com.github.libretube.util.PreferenceHelper +import com.github.libretube.util.TextUtils import com.google.android.exoplayer2.C import com.google.android.exoplayer2.DefaultLoadControl import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MediaItem.SubtitleConfiguration -import com.google.android.exoplayer2.MediaItem.fromUri import com.google.android.exoplayer2.PlaybackException import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.audio.AudioAttributes import com.google.android.exoplayer2.ext.cronet.CronetDataSource import com.google.android.exoplayer2.source.DefaultMediaSourceFactory -import com.google.android.exoplayer2.source.MediaSource -import com.google.android.exoplayer2.source.MergingMediaSource -import com.google.android.exoplayer2.source.ProgressiveMediaSource +import com.google.android.exoplayer2.text.Cue.TEXT_SIZE_TYPE_ABSOLUTE import com.google.android.exoplayer2.trackselection.DefaultTrackSelector import com.google.android.exoplayer2.ui.CaptionStyleCompat import com.google.android.exoplayer2.ui.StyledPlayerView -import com.google.android.exoplayer2.upstream.DataSource import com.google.android.exoplayer2.upstream.DefaultDataSource -import com.google.android.exoplayer2.upstream.DefaultHttpDataSource +import com.google.android.exoplayer2.util.MimeTypes import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -99,10 +108,11 @@ import kotlinx.coroutines.launch import org.chromium.net.CronetEngine import retrofit2.HttpException import java.io.IOException +import java.util.* import java.util.concurrent.Executors import kotlin.math.abs -class PlayerFragment : BaseFragment() { +class PlayerFragment : BaseFragment(), OnlinePlayerOptions { lateinit var binding: FragmentPlayerBinding private lateinit var playerBinding: ExoStyledPlayerControlViewBinding @@ -114,9 +124,8 @@ class PlayerFragment : BaseFragment() { */ private var videoId: String? = null private var playlistId: String? = null - private var isSubscribed: Boolean? = false private var isLive = false - private lateinit var streams: com.github.libretube.api.obj.Streams + private lateinit var streams: Streams /** * for the transition @@ -129,7 +138,6 @@ class PlayerFragment : BaseFragment() { * for the comments */ private var commentsAdapter: CommentsAdapter? = null - private var commentsLoaded: Boolean? = false private var nextPage: String? = null private var isLoading = true @@ -138,27 +146,21 @@ class PlayerFragment : BaseFragment() { */ private lateinit var exoPlayer: ExoPlayer private lateinit var trackSelector: DefaultTrackSelector - private lateinit var segmentData: com.github.libretube.api.obj.Segments - private lateinit var chapters: List + private lateinit var segmentData: SegmentData + private lateinit var chapters: List /** * for the player view */ private lateinit var exoPlayerView: StyledPlayerView - private var subtitle = mutableListOf() + private var subtitles = mutableListOf() /** * user preferences */ - private var token = PreferenceHelper.getToken() + private val token = PreferenceHelper.getToken() private var videoShownInExternalPlayer = false - /** - * for autoplay - */ - private var nextStreamId: String? = null - private lateinit var autoPlayHelper: AutoPlayHelper - /** * for the player notification */ @@ -186,25 +188,14 @@ class PlayerFragment : BaseFragment() { return binding.root } - @SuppressLint("SourceLockedOrientationActivity") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) context?.hideKeyboard(view) // clear the playing queue - PlayingQueue.clear() + PlayingQueue.resetToDefaults() - setUserPrefs() - - val mainActivity = activity as MainActivity - if (PlayerHelper.autoRotationEnabled) { - // enable auto rotation - mainActivity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR - onConfigurationChanged(resources.configuration) - } else { - // go to portrait mode - mainActivity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT - } + changeOrientationMode() createExoPlayer() initializeTransitionLayout() @@ -224,14 +215,6 @@ class PlayerFragment : BaseFragment() { Handler(Looper.getMainLooper()).postDelayed(this::showBottomBar, 100) } - private fun setUserPrefs() { - token = PreferenceHelper.getToken() - - // save whether auto rotation is enabled - - // save whether related streams are enabled - } - @SuppressLint("ClickableViewAccessibility") private fun initializeTransitionLayout() { val mainActivity = activity as MainActivity @@ -255,6 +238,7 @@ class PlayerFragment : BaseFragment() { mainActivity.binding.mainMotionLayout mainMotionLayout.progress = abs(progress) exoPlayerView.hideController() + exoPlayerView.useController = false eId = endId sId = startId } @@ -267,10 +251,12 @@ class PlayerFragment : BaseFragment() { viewModel.isMiniPlayerVisible.value = true exoPlayerView.useController = false mainMotionLayout.progress = 1F + (activity as MainActivity).requestOrientationChange() } else if (currentId == sId) { viewModel.isMiniPlayerVisible.value = false exoPlayerView.useController = true mainMotionLayout.progress = 0F + changeOrientationMode() } } @@ -299,76 +285,6 @@ class PlayerFragment : BaseFragment() { } } - private val onlinePlayerOptionsInterface = object : PlayerOptionsInterface { - override fun onCaptionClicked() { - if (!this@PlayerFragment::streams.isInitialized || - streams.subtitles == null || - streams.subtitles!!.isEmpty() - ) { - Toast.makeText(context, R.string.no_subtitles_available, Toast.LENGTH_SHORT).show() - return - } - - val subtitlesNamesList = mutableListOf(context?.getString(R.string.none)!!) - val subtitleCodesList = mutableListOf("") - streams.subtitles!!.forEach { - subtitlesNamesList += it.name!! - subtitleCodesList += it.code!! - } - - BottomSheet() - .setSimpleItems(subtitlesNamesList) { index -> - val newParams = if (index != 0) { - // caption selected - - // get the caption language code - val captionLanguageCode = subtitleCodesList[index] - - // select the new caption preference - trackSelector.buildUponParameters() - .setPreferredTextLanguage(captionLanguageCode) - .setPreferredTextRoleFlags(C.ROLE_FLAG_CAPTION) - } else { - // none selected - // disable captions - trackSelector.buildUponParameters() - .setPreferredTextLanguage("") - } - - // set the new caption language - trackSelector.setParameters(newParams) - } - .show(childFragmentManager) - } - - override fun onQualityClicked() { - // get the available resolutions - val (videosNameArray, videosUrlArray) = getAvailableResolutions() - - // Dialog for quality selection - val lastPosition = exoPlayer.currentPosition - BottomSheet() - .setSimpleItems( - videosNameArray.toList() - ) { which -> - if ( - videosNameArray[which] == getString(R.string.hls) || - videosNameArray[which] == "LBRY HLS" - ) { - // set the progressive media source - setHLSMediaSource(videosUrlArray[which]) - } else { - val videoUri = videosUrlArray[which] - val audioUrl = - PlayerHelper.getAudioSource(requireContext(), streams.audioStreams!!) - setMediaSource(videoUri, audioUrl) - } - exoPlayer.seekTo(lastPosition) - } - .show(childFragmentManager) - } - } - // actions that don't depend on video information private fun initializeOnClickActions() { binding.closeImageView.setOnClickListener { @@ -392,6 +308,10 @@ class PlayerFragment : BaseFragment() { if (!exoPlayer.isPlaying) { // start or go on playing binding.playImageView.setImageResource(R.drawable.ic_pause) + if (exoPlayer.playbackState == Player.STATE_ENDED) { + // restart video if finished + exoPlayer.seekTo(0) + } exoPlayer.play() } else { // pause the video @@ -409,10 +329,15 @@ class PlayerFragment : BaseFragment() { toggleComments() } + playerBinding.queueToggle.visibility = View.VISIBLE + playerBinding.queueToggle.setOnClickListener { + PlayingQueueSheet().show(childFragmentManager, null) + } + // FullScreen button trigger // hide fullscreen button if auto rotation enabled playerBinding.fullscreen.visibility = - if (PlayerHelper.autoRotationEnabled) View.GONE else View.VISIBLE + if (PlayerHelper.autoRotationEnabled) View.INVISIBLE else View.VISIBLE playerBinding.fullscreen.setOnClickListener { // hide player controller exoPlayerView.hideController() @@ -427,8 +352,16 @@ class PlayerFragment : BaseFragment() { // share button binding.relPlayerShare.setOnClickListener { + if (!this::streams.isInitialized) return@setOnClickListener val shareDialog = - ShareDialog(videoId!!, ShareObjectType.VIDEO, exoPlayer.currentPosition / 1000) + ShareDialog( + videoId!!, + ShareObjectType.VIDEO, + ShareData( + currentVideo = streams.title, + currentPosition = exoPlayer.currentPosition / 1000 + ) + ) shareDialog.show(childFragmentManager, ShareDialog::class.java.name) } @@ -440,7 +373,8 @@ class PlayerFragment : BaseFragment() { BackgroundHelper.playOnBackground( requireContext(), videoId!!, - exoPlayer.currentPosition + exoPlayer.currentPosition, + playlistId ) } @@ -457,8 +391,13 @@ class PlayerFragment : BaseFragment() { binding.commentsRecView.layoutManager = LinearLayoutManager(view?.context) binding.commentsRecView.setItemViewCacheSize(20) - binding.relatedRecView.layoutManager = - GridLayoutManager(view?.context, resources.getInteger(R.integer.grid_items)) + binding.relatedRecView.layoutManager = VideosAdapter.getLayout(requireContext()) + + binding.alternativeTrendingRec.layoutManager = LinearLayoutManager( + context, + LinearLayoutManager.HORIZONTAL, + false + ) } private fun setFullscreen() { @@ -526,11 +465,14 @@ class PlayerFragment : BaseFragment() { } private fun toggleComments() { - binding.commentsRecView.visibility = - if (binding.commentsRecView.isVisible) View.GONE else View.VISIBLE - binding.relatedRecView.visibility = - if (binding.relatedRecView.isVisible) View.GONE else View.VISIBLE - if (!commentsLoaded!!) fetchComments() + if (binding.commentsRecView.isVisible) { + binding.commentsRecView.visibility = View.GONE + binding.relatedRecView.visibility = View.VISIBLE + } else { + binding.commentsRecView.visibility = View.VISIBLE + binding.relatedRecView.visibility = View.GONE + if (binding.commentsRecView.isEmpty()) fetchComments() + } } override fun onStart() { @@ -559,11 +501,12 @@ class PlayerFragment : BaseFragment() { override fun onDestroy() { super.onDestroy() try { - // clear the playing queue - PlayingQueue.clear() - saveWatchPosition() + + // clear the playing queue and release the player + PlayingQueue.resetToDefaults() nowPlayingNotification.destroySelfAndPlayer() + activity?.requestedOrientation = if ((activity as MainActivity).autoRotationEnabled) { ActivityInfo.SCREEN_ORIENTATION_USER @@ -577,14 +520,17 @@ class PlayerFragment : BaseFragment() { // save the watch position if video isn't finished and option enabled private fun saveWatchPosition() { - if (PlayerHelper.watchPositionsEnabled && exoPlayer.currentPosition != exoPlayer.duration) { - DatabaseHelper.saveWatchPosition( - videoId!!, - exoPlayer.currentPosition - ) - } else if (PlayerHelper.watchPositionsEnabled) { - // delete watch position if video has ended - DatabaseHelper.removeWatchPosition(videoId!!) + if (!PlayerHelper.watchPositionsEnabled) return + Log.e( + "watchpositions", + PreferenceHelper.getBoolean( + PreferenceKeys.WATCH_POSITION_TOGGLE, + true + ).toString() + ) + val watchPosition = WatchPosition(videoId!!, exoPlayer.currentPosition) + query { + Database.watchPositionDao().insertAll(watchPosition) } } @@ -596,8 +542,8 @@ class PlayerFragment : BaseFragment() { if (!::segmentData.isInitialized || segmentData.segments.isEmpty()) return val currentPosition = exoPlayer.currentPosition - segmentData.segments.forEach { segment: com.github.libretube.api.obj.Segment -> - val segmentStart = (segment.segment!![0] * 1000f).toLong() + segmentData.segments.forEach { segment: Segment -> + val segmentStart = (segment.segment[0] * 1000f).toLong() val segmentEnd = (segment.segment[1] * 1000f).toLong() // show the button to manually skip the segment @@ -629,9 +575,9 @@ class PlayerFragment : BaseFragment() { } private fun playVideo() { - lifecycleScope.launchWhenCreated { - PlayingQueue.updateCurrent(videoId!!) + playerBinding.exoProgress.clearSegments() + lifecycleScope.launchWhenCreated { streams = try { RetrofitInstance.api.getStreams(videoId!!) } catch (e: IOException) { @@ -645,6 +591,27 @@ class PlayerFragment : BaseFragment() { return@launchWhenCreated } + if (PlayingQueue.isEmpty()) { + CoroutineScope(Dispatchers.IO).launch { + if (playlistId != null) { + PlayingQueue.insertPlaylist(playlistId!!, streams.toStreamItem(videoId!!)) + } else { + PlayingQueue.updateCurrent(streams.toStreamItem(videoId!!)) + if (PlayerHelper.autoInsertRelatedVideos) { + PlayingQueue.add( + *streams.relatedStreams.orEmpty().toTypedArray() + ) + } + } + } + } else { + PlayingQueue.updateCurrent(streams.toStreamItem(videoId!!)) + } + + PlayingQueue.setOnQueueTapListener { streamItem -> + streamItem.url?.toID()?.let { playNextVideo(it) } + } + runOnUiThread { // hide the button to skip SponsorBlock segments manually binding.sbSkipBtn.visibility = View.GONE @@ -666,47 +633,32 @@ class PlayerFragment : BaseFragment() { // show the player notification initializePlayerNotification() if (PlayerHelper.sponsorBlockEnabled) fetchSponsorBlockSegments() - // show comments if related streams disabled - if (!PlayerHelper.relatedStreamsEnabled) toggleComments() - // prepare for autoplay - if (binding.player.autoplayEnabled) setNextStream() + + // show comments if related streams are disabled or the alternative related streams layout is chosen + if (!PlayerHelper.relatedStreamsEnabled || PlayerHelper.alternativeVideoLayout) toggleComments() // add the video to the watch history if (PlayerHelper.watchHistoryEnabled) { - DatabaseHelper.addToWatchHistory( - videoId!!, - streams - ) + DatabaseHelper.addToWatchHistory(videoId!!, streams) } } } } - /** - * set the videoId of the next stream for autoplay - */ - private fun setNextStream() { - if (!this::autoPlayHelper.isInitialized) autoPlayHelper = AutoPlayHelper(playlistId) - // search for the next videoId in the playlist - lifecycleScope.launchWhenCreated { - nextStreamId = autoPlayHelper.getNextVideoId(videoId!!, streams.relatedStreams) - } - } - /** * fetch the segments for SponsorBlock */ private fun fetchSponsorBlockSegments() { CoroutineScope(Dispatchers.IO).launch { - kotlin.runCatching { + runCatching { val categories = PlayerHelper.getSponsorBlockCategories() - if (categories.size > 0) { - segmentData = - RetrofitInstance.api.getSegments( - videoId!!, - ObjectMapper().writeValueAsString(categories) - ) - } + if (categories.isEmpty()) return@runCatching + segmentData = + RetrofitInstance.api.getSegments( + videoId!!, + ObjectMapper().writeValueAsString(categories) + ) + playerBinding.exoProgress.setSegments(segmentData.segments) } } } @@ -757,19 +709,19 @@ class PlayerFragment : BaseFragment() { } // used for autoplay and skipping to next video - private fun playNextVideo() { - if (nextStreamId == null) return - // check whether there is a new video in the queue - val nextQueueVideo = PlayingQueue.getNext() - if (nextQueueVideo != null) nextStreamId = nextQueueVideo + private fun playNextVideo(nextId: String? = null) { + val nextVideoId = nextId ?: PlayingQueue.getNext() // by making sure that the next and the current video aren't the same saveWatchPosition() - // forces the comments to reload for the new video - commentsLoaded = false - binding.commentsRecView.adapter = null + // save the id of the next stream as videoId and load the next video - videoId = nextStreamId - playVideo() + if (nextVideoId != null) { + videoId = nextVideoId + + // forces the comments to reload for the new video + binding.commentsRecView.adapter = null + playVideo() + } } private fun prepareExoPlayerView() { @@ -783,11 +735,19 @@ class PlayerFragment : BaseFragment() { player = exoPlayer } - if (PlayerHelper.useSystemCaptionStyle) { - // set the subtitle style - val captionStyle = PlayerHelper.getCaptionStyle(requireContext()) - exoPlayerView.subtitleView?.setApplyEmbeddedStyles(captionStyle == CaptionStyleCompat.DEFAULT) - exoPlayerView.subtitleView?.setStyle(captionStyle) + playerBinding.exoProgress.setPlayer(exoPlayer) + + applyCaptionStyle() + } + + private fun applyCaptionStyle() { + val captionStyle = PlayerHelper.getCaptionStyle(requireContext()) + exoPlayerView.subtitleView?.apply { + setApplyEmbeddedFontSizes(false) + setFixedTextSize(TEXT_SIZE_TYPE_ABSOLUTE, 18F) + if (!PlayerHelper.useSystemCaptionStyle) return + setApplyEmbeddedStyles(captionStyle == CaptionStyleCompat.DEFAULT) + setStyle(captionStyle) } } @@ -801,11 +761,10 @@ class PlayerFragment : BaseFragment() { } @SuppressLint("SetTextI18n") - private fun initializePlayerView(response: com.github.libretube.api.obj.Streams) { + private fun initializePlayerView(response: Streams) { // initialize the player view actions binding.player.initialize( - childFragmentManager, - onlinePlayerOptionsInterface, + this, doubleTapOverlayBinding, trackSelector ) @@ -813,7 +772,7 @@ class PlayerFragment : BaseFragment() { binding.apply { playerViewsInfo.text = context?.getString(R.string.views, response.views.formatShort()) + - if (!isLive) " • " + response.uploadDate else "" + if (!isLive) TextUtils.SEPARATOR + response.uploadDate else "" textLike.text = response.likes.formatShort() textDislike.text = response.dislikes.formatShort() @@ -866,7 +825,6 @@ class PlayerFragment : BaseFragment() { @Suppress("DEPRECATION") if ( playbackState == Player.STATE_ENDED && - nextStreamId != null && !transitioning && binding.player.autoplayEnabled ) { @@ -875,22 +833,27 @@ class PlayerFragment : BaseFragment() { if (binding.player.autoplayEnabled) playNextVideo() } - if (playbackState == Player.STATE_READY) { - // media actually playing - transitioning = false - binding.playImageView.setImageResource(R.drawable.ic_pause) - } else { - // player paused in any state - binding.playImageView.setImageResource(R.drawable.ic_play) + when (playbackState) { + Player.STATE_READY -> { + // media actually playing + transitioning = false + binding.playImageView.setImageResource(R.drawable.ic_pause) + } + Player.STATE_ENDED -> { + // video has finished + binding.playImageView.setImageResource(R.drawable.ic_restart) + } + else -> { + // player in any other state + binding.playImageView.setImageResource(R.drawable.ic_play) + } } // save the watch position when paused if (playbackState == PlaybackState.STATE_PAUSED) { + val watchPosition = WatchPosition(videoId!!, exoPlayer.currentPosition) query { - DatabaseHelper.saveWatchPosition( - videoId!!, - exoPlayer.currentPosition - ) + Database.watchPositionDao().insertAll(watchPosition) } } @@ -915,20 +878,16 @@ class PlayerFragment : BaseFragment() { } }) - // check if livestream - if (response.duration > 0) { - // download clicked - binding.relPlayerDownload.setOnClickListener { - if (!DownloadService.IS_DOWNLOAD_RUNNING) { - val newFragment = DownloadDialog(videoId!!) - newFragment.show(childFragmentManager, DownloadDialog::class.java.name) - } else { - Toast.makeText(context, R.string.dlisinprogress, Toast.LENGTH_SHORT) - .show() - } + binding.relPlayerDownload.setOnClickListener { + if (response.duration <= 0) { + Toast.makeText(context, R.string.cannotDownload, Toast.LENGTH_SHORT).show() + } else if (!DownloadService.IS_DOWNLOAD_RUNNING) { + val newFragment = DownloadDialog(videoId!!) + newFragment.show(childFragmentManager, DownloadDialog::class.java.name) + } else { + Toast.makeText(context, R.string.dlisinprogress, Toast.LENGTH_SHORT) + .show() } - } else { - Toast.makeText(context, R.string.cannotDownload, Toast.LENGTH_SHORT).show() } if (response.hls != null) { @@ -954,13 +913,7 @@ class PlayerFragment : BaseFragment() { } } } - if (PlayerHelper.relatedStreamsEnabled) { - // only show related streams if enabled - binding.relatedRecView.adapter = TrendingAdapter( - response.relatedStreams!!, - childFragmentManager - ) - } + initializeRelatedVideos(response.relatedStreams) // set video description val description = response.description!! binding.playerDescription.text = @@ -986,20 +939,16 @@ class PlayerFragment : BaseFragment() { } // update the subscribed state - isSubscribed() + binding.playerSubscribe.setupSubscriptionButton( + streams.uploaderUrl?.toID(), + streams.uploader + ) - if (token != "") { - binding.relPlayerSave.setOnClickListener { - val newFragment = AddToPlaylistDialog() - val bundle = Bundle() - bundle.putString(IntentData.videoId, videoId) - newFragment.arguments = bundle - newFragment.show(childFragmentManager, AddToPlaylistDialog::class.java.name) - } - } else { - binding.relPlayerSave.setOnClickListener { - Toast.makeText(context, R.string.login_first, Toast.LENGTH_SHORT).show() - } + binding.relPlayerSave.setOnClickListener { + AddToPlaylistDialog(videoId!!).show( + childFragmentManager, + AddToPlaylistDialog::class.java.name + ) } // next and previous buttons @@ -1023,6 +972,21 @@ class PlayerFragment : BaseFragment() { } } + private fun initializeRelatedVideos(relatedStreams: List?) { + if (!PlayerHelper.relatedStreamsEnabled) return + + if (PlayerHelper.alternativeVideoLayout) { + binding.alternativeTrendingRec.adapter = VideosAdapter( + relatedStreams.orEmpty().toMutableList(), + forceMode = VideosAdapter.Companion.ForceMode.RELATED + ) + } else { + binding.relatedRecView.adapter = VideosAdapter( + relatedStreams.orEmpty().toMutableList() + ) + } + } + private fun initializeChapters() { if (chapters.isEmpty()) { binding.chaptersRecView.visibility = View.GONE @@ -1043,9 +1007,8 @@ class PlayerFragment : BaseFragment() { binding.chaptersRecView.adapter = ChaptersAdapter(chapters, exoPlayer) // enable the chapters dialog in the player - val titles = mutableListOf() - chapters.forEach { - titles += it.title!! + val titles = chapters.map { chapter -> + "${chapter.title} (${chapter.start?.let { DateUtils.formatElapsedTime(it) }})" } playerBinding.chapterLL.setOnClickListener { if (viewModel.isFullscreen.value!!) { @@ -1101,82 +1064,77 @@ class PlayerFragment : BaseFragment() { return chapterIndex } - private fun setMediaSource( - videoUri: Uri, - audioUrl: String - ) { - val checkIntervalSize = when (PlayerHelper.progressiveLoadingIntervalSize) { - "default" -> ProgressiveMediaSource.DEFAULT_LOADING_CHECK_INTERVAL_BYTES - else -> PlayerHelper.progressiveLoadingIntervalSize.toInt() * 1024 - } - - val dataSourceFactory: DataSource.Factory = - DefaultHttpDataSource.Factory() - - val videoItem: MediaItem = MediaItem.Builder() - .setUri(videoUri) - .setSubtitleConfigurations(subtitle) - .build() - - val videoSource: MediaSource = - ProgressiveMediaSource.Factory(dataSourceFactory) - .setContinueLoadingCheckIntervalBytes(checkIntervalSize) - .createMediaSource(videoItem) - - val audioSource: MediaSource = - ProgressiveMediaSource.Factory(dataSourceFactory) - .setContinueLoadingCheckIntervalBytes(checkIntervalSize) - .createMediaSource(fromUri(audioUrl)) - - val mergeSource: MediaSource = - MergingMediaSource(videoSource, audioSource) - exoPlayer.setMediaSource(mergeSource) - } - - private fun setHLSMediaSource(uri: Uri) { + private fun setMediaSource(uri: Uri, mimeType: String) { val mediaItem: MediaItem = MediaItem.Builder() .setUri(uri) - .setSubtitleConfigurations(subtitle) + .setMimeType(mimeType) + .setSubtitleConfigurations(subtitles) .build() exoPlayer.setMediaItem(mediaItem) } - private fun getAvailableResolutions(): Pair, Array> { - if (!this::streams.isInitialized) return Pair(arrayOf(), arrayOf()) + private fun String?.qualityToInt(): Int { + this ?: return 0 + return this.toString().split("p").first().toInt() + } - var videosNameArray: Array = arrayOf() - var videosUrlArray: Array = arrayOf() + private fun getAvailableResolutions(): List { + if (!this::streams.isInitialized) return listOf() - // append hls to list if available - if (streams.hls != null) { - videosNameArray += getString(R.string.hls) - videosUrlArray += streams.hls!!.toUri() + val resolutions = mutableListOf() + + val videoStreams = try { + // attempt to sort the qualities, catch if there was an error ih parsing + streams.videoStreams?.sortedBy { + it.quality?.toLong() ?: 0L + }?.reversed() + .orEmpty() + } catch (_: Exception) { + streams.videoStreams.orEmpty() } - for (vid in streams.videoStreams!!) { + for (vid in videoStreams) { + if (resolutions.any { + it.resolution == vid.quality.qualityToInt() + } || vid.url == null + ) { + continue + } + + runCatching { + resolutions.add( + VideoResolution( + name = "${vid.quality.qualityToInt()}p", + resolution = vid.quality.qualityToInt() + ) + ) + } + + /* // append quality to list if it has the preferred format (e.g. MPEG) val preferredMimeType = "video/${PlayerHelper.videoFormatPreference}" - if (vid.url != null && vid.mimeType == preferredMimeType) { // preferred format - videosNameArray += vid.quality.toString() - videosUrlArray += vid.url!!.toUri() - } else if (vid.quality.equals("LBRY") && vid.format.equals("MP4")) { // LBRY MP4 format - videosNameArray += "LBRY MP4" - videosUrlArray += vid.url!!.toUri() - } + if (vid.url != null && vid.mimeType == preferredMimeType) + */ } - return Pair(videosNameArray, videosUrlArray) + + if (resolutions.isEmpty()) { + return listOf( + VideoResolution(getString(R.string.hls), adaptiveSourceUrl = streams.hls) + ) + } + + resolutions.add(0, VideoResolution(getString(R.string.auto_quality), null)) + + return resolutions } private fun setResolutionAndSubtitles() { - // get the available resolutions - val (videosNameArray, videosUrlArray) = getAvailableResolutions() - // create a list of subtitles - subtitle = mutableListOf() + subtitles = mutableListOf() val subtitlesNamesList = mutableListOf(context?.getString(R.string.none)!!) val subtitleCodesList = mutableListOf("") - streams.subtitles!!.forEach { - subtitle.add( + streams.subtitles.orEmpty().forEach { + subtitles.add( SubtitleConfiguration.Builder(it.url!!.toUri()) .setMimeType(it.mimeType!!) // The correct MIME type (required). .setLanguage(it.code) // The subtitle language (optional). @@ -1187,51 +1145,55 @@ class PlayerFragment : BaseFragment() { } // set the default subtitle if available + val newParams = trackSelector.buildUponParameters() if (PlayerHelper.defaultSubtitleCode != "" && subtitleCodesList.contains(PlayerHelper.defaultSubtitleCode)) { - val newParams = trackSelector.buildUponParameters() + newParams .setPreferredTextLanguage(PlayerHelper.defaultSubtitleCode) .setPreferredTextRoleFlags(C.ROLE_FLAG_CAPTION) - trackSelector.setParameters(newParams) + } else { + newParams.setPreferredTextLanguage(null) } + trackSelector.setParameters(newParams) + // set media source and resolution in the beginning setStreamSource( - streams, - videosNameArray, - videosUrlArray + streams ) } - private fun setStreamSource( - streams: com.github.libretube.api.obj.Streams, - videosNameArray: Array, - videosUrlArray: Array - ) { - val defaultResolution = PlayerHelper.getDefaultResolution(requireContext()) - if (defaultResolution != "") { - videosNameArray.forEachIndexed { index, pipedStream -> - // search for quality preference in the available stream sources - if (pipedStream.contains(defaultResolution)) { - val videoUri = videosUrlArray[index] - val audioUrl = - PlayerHelper.getAudioSource(requireContext(), streams.audioStreams!!) - setMediaSource(videoUri, audioUrl) - return - } + private fun setPlayerResolution(resolution: Int?) { + val params = trackSelector.buildUponParameters() + when (resolution) { + null -> params.setMaxVideoSize(Int.MAX_VALUE, Int.MAX_VALUE).setMinVideoSize(0, 0) + else -> params.setMaxVideoSize(Int.MAX_VALUE, resolution).setMinVideoSize(Int.MIN_VALUE, resolution) + } + trackSelector.setParameters(params) + } + + private fun setStreamSource(streams: Streams) { + val defaultResolution = PlayerHelper.getDefaultResolution(requireContext()).replace("p", "") + if (defaultResolution != "") setPlayerResolution(defaultResolution.toInt()) + + if (!PreferenceHelper.getBoolean(PreferenceKeys.USE_HLS_OVER_DASH, false) && + streams.videoStreams.orEmpty().isNotEmpty() + ) { + val uri = let { + streams.dash?.toUri() + + val manifest = DashHelper.createManifest(streams) + + // encode to base64 + val encoded = Base64.encodeToString(manifest.toByteArray(), Base64.DEFAULT) + + "data:application/dash+xml;charset=utf-8;base64,$encoded".toUri() } - } - // if default resolution isn't set or available, use hls if available - if (streams.hls != null) { - setHLSMediaSource(Uri.parse(streams.hls)) - return - } - - // if nothing found, use the first list entry - if (videosUrlArray.isNotEmpty()) { - val videoUri = videosUrlArray[0] - val audioUrl = PlayerHelper.getAudioSource(requireContext(), streams.audioStreams!!) - setMediaSource(videoUri, audioUrl) + this.setMediaSource(uri, MimeTypes.APPLICATION_MPD) + } else if (streams.hls != null) { + setMediaSource(streams.hls.toUri(), MimeTypes.APPLICATION_M3U8) + } else { + Toast.makeText(context, R.string.unknown_error, Toast.LENGTH_SHORT).show() } } @@ -1266,17 +1228,10 @@ class PlayerFragment : BaseFragment() { // control for the track sources like subtitles and audio source trackSelector = DefaultTrackSelector(requireContext()) - // limit hls to full hd - if ( - PreferenceHelper.getBoolean( - PreferenceKeys.LIMIT_HLS, - false - ) - ) { - val newParams = trackSelector.buildUponParameters() - .setMaxVideoSize(1920, 1080) - trackSelector.setParameters(newParams) - } + val params = trackSelector.buildUponParameters().setPreferredAudioLanguage( + Locale.getDefault().language.lowercase().substring(0, 2) + ) + trackSelector.setParameters(params) exoPlayer = ExoPlayer.Builder(requireContext()) .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) @@ -1298,32 +1253,6 @@ class PlayerFragment : BaseFragment() { nowPlayingNotification.updatePlayerNotification(videoId!!, streams) } - private fun isSubscribed() { - val channelId = streams.uploaderUrl!!.toID() - lifecycleScope.launchWhenCreated { - isSubscribed = SubscriptionHelper.isSubscribed(channelId) - - if (isSubscribed == null) return@launchWhenCreated - - runOnUiThread { - if (isSubscribed == true) { - binding.playerSubscribe.text = getString(R.string.unsubscribe) - } - binding.playerSubscribe.setOnClickListener { - if (isSubscribed == true) { - SubscriptionHelper.unsubscribe(channelId) - binding.playerSubscribe.text = getString(R.string.subscribe) - isSubscribed = false - } else { - SubscriptionHelper.subscribe(channelId) - binding.playerSubscribe.text = getString(R.string.unsubscribe) - isSubscribed = true - } - } - } - } - } - private fun fetchComments() { lifecycleScope.launchWhenCreated { val commentsResponse = try { @@ -1340,7 +1269,6 @@ class PlayerFragment : BaseFragment() { commentsAdapter = CommentsAdapter(videoId!!, commentsResponse.comments) binding.commentsRecView.adapter = commentsAdapter nextPage = commentsResponse.nextpage - commentsLoaded = true isLoading = false } } @@ -1366,6 +1294,97 @@ class PlayerFragment : BaseFragment() { } } + /** + * Use the sensor mode if auto fullscreen is enabled + */ + @SuppressLint("SourceLockedOrientationActivity") + private fun changeOrientationMode() { + val mainActivity = activity as MainActivity + if (PlayerHelper.autoRotationEnabled) { + // enable auto rotation + mainActivity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR + onConfigurationChanged(resources.configuration) + } else { + // go to portrait mode + mainActivity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT + } + } + + override fun onCaptionsClicked() { + if (!this@PlayerFragment::streams.isInitialized || + streams.subtitles == null || + streams.subtitles!!.isEmpty() + ) { + Toast.makeText(context, R.string.no_subtitles_available, Toast.LENGTH_SHORT).show() + return + } + + val subtitlesNamesList = mutableListOf(context?.getString(R.string.none)!!) + val subtitleCodesList = mutableListOf("") + streams.subtitles!!.forEach { + subtitlesNamesList += it.name!! + subtitleCodesList += it.code!! + } + + BaseBottomSheet() + .setSimpleItems(subtitlesNamesList) { index -> + val newParams = if (index != 0) { + // caption selected + + // get the caption language code + val captionLanguageCode = subtitleCodesList[index] + + // select the new caption preference + trackSelector.buildUponParameters() + .setPreferredTextLanguage(captionLanguageCode) + .setPreferredTextRoleFlags(C.ROLE_FLAG_CAPTION) + } else { + // none selected + // disable captions + trackSelector.buildUponParameters() + .setPreferredTextLanguage(null) + } + + // set the new caption language + trackSelector.setParameters(newParams) + } + .show(childFragmentManager) + } + + override fun onQualityClicked() { + // get the available resolutions + val resolutions = getAvailableResolutions() + + // Dialog for quality selection + BaseBottomSheet() + .setSimpleItems( + resolutions.map { it.name } + ) { which -> + setPlayerResolution(resolutions[which].resolution) + } + .show(childFragmentManager) + } + + private fun getAudioStreamGroups(audioStreams: List?): Map> { + return audioStreams.orEmpty() + .groupBy { it.audioTrackName } + } + + override fun onAudioStreamClicked() { + val audioGroups = getAudioStreamGroups(streams.audioStreams) + val audioLanguages = audioGroups.map { it.key ?: getString(R.string.default_audio_track) } + + BaseBottomSheet() + .setSimpleItems(audioLanguages) { index -> + val audioStreams = audioGroups.values.elementAt(index) + val lang = audioStreams.firstOrNull()?.audioTrackId?.substring(0, 2) + val newParams = trackSelector.buildUponParameters() + .setPreferredAudioLanguage(lang) + trackSelector.setParameters(newParams) + } + .show(childFragmentManager) + } + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { super.onPictureInPictureModeChanged(isInPictureInPictureMode) if (isInPictureInPictureMode) { diff --git a/app/src/main/java/com/github/libretube/ui/fragments/PlaylistFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/PlaylistFragment.kt index 931d71d9e..168926a41 100644 --- a/app/src/main/java/com/github/libretube/ui/fragments/PlaylistFragment.kt +++ b/app/src/main/java/com/github/libretube/ui/fragments/PlaylistFragment.kt @@ -1,5 +1,6 @@ package com.github.libretube.ui.fragments +import android.annotation.SuppressLint import android.os.Bundle import android.util.Log import android.view.LayoutInflater @@ -10,14 +11,24 @@ import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.github.libretube.R +import com.github.libretube.api.PlaylistsHelper import com.github.libretube.api.RetrofitInstance import com.github.libretube.constants.IntentData import com.github.libretube.databinding.FragmentPlaylistBinding +import com.github.libretube.db.DatabaseHolder +import com.github.libretube.db.obj.PlaylistBookmark +import com.github.libretube.enums.PlaylistType import com.github.libretube.extensions.TAG +import com.github.libretube.extensions.awaitQuery +import com.github.libretube.extensions.query import com.github.libretube.extensions.toID import com.github.libretube.ui.adapters.PlaylistAdapter import com.github.libretube.ui.base.BaseFragment +import com.github.libretube.ui.extensions.serializable import com.github.libretube.ui.sheets.PlaylistOptionsBottomSheet +import com.github.libretube.util.ImageHelper +import com.github.libretube.util.NavigationHelper +import com.github.libretube.util.TextUtils import retrofit2.HttpException import java.io.IOException @@ -25,16 +36,18 @@ class PlaylistFragment : BaseFragment() { private lateinit var binding: FragmentPlaylistBinding private var playlistId: String? = null - private var isOwner: Boolean = false + private var playlistName: String? = null + private var playlistType: PlaylistType = PlaylistType.PUBLIC private var nextPage: String? = null private var playlistAdapter: PlaylistAdapter? = null private var isLoading = true + private var isBookmarked = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) arguments?.let { playlistId = it.getString(IntentData.playlistId) - isOwner = it.getBoolean("isOwner") + playlistType = it.serializable(IntentData.playlistType)!! } } @@ -54,18 +67,27 @@ class PlaylistFragment : BaseFragment() { binding.playlistRecView.layoutManager = LinearLayoutManager(context) binding.playlistProgress.visibility = View.VISIBLE + + isBookmarked = awaitQuery { + DatabaseHolder.Database.playlistBookmarkDao().includes(playlistId!!) + } + updateBookmarkRes() + fetchPlaylist() } + private fun updateBookmarkRes() { + binding.bookmark.setIconResource( + if (isBookmarked) R.drawable.ic_bookmark else R.drawable.ic_bookmark_outlined + ) + } + + @SuppressLint("SetTextI18n") private fun fetchPlaylist() { + binding.playlistScrollview.visibility = View.GONE lifecycleScope.launchWhenCreated { val response = try { - // load locally stored playlists with the auth api - if (isOwner) { - RetrofitInstance.authApi.getPlaylist(playlistId!!) - } else { - RetrofitInstance.api.getPlaylist(playlistId!!) - } + PlaylistsHelper.getPlaylist(playlistType, playlistId!!) } catch (e: IOException) { println(e) Log.e(TAG(), "IOException, you might not have internet connection") @@ -74,42 +96,77 @@ class PlaylistFragment : BaseFragment() { Log.e(TAG(), "HttpException, unexpected response") return@launchWhenCreated } + binding.playlistScrollview.visibility = View.VISIBLE nextPage = response.nextpage + playlistName = response.name isLoading = false runOnUiThread { + ImageHelper.loadImage(response.thumbnailUrl, binding.thumbnail) binding.playlistProgress.visibility = View.GONE binding.playlistName.text = response.name - binding.uploader.text = response.uploader - binding.videoCount.text = + + binding.playlistName.setOnClickListener { + binding.playlistName.maxLines = if (binding.playlistName.maxLines == 2) Int.MAX_VALUE else 2 + } + + binding.playlistInfo.text = (if (response.uploader != null) response.uploader + TextUtils.SEPARATOR else "") + getString(R.string.videoCount, response.videos.toString()) // show playlist options binding.optionsMenu.setOnClickListener { - val optionsDialog = - PlaylistOptionsBottomSheet(playlistId!!, isOwner) - optionsDialog.show( + PlaylistOptionsBottomSheet(playlistId!!, playlistName ?: "", playlistType).show( childFragmentManager, PlaylistOptionsBottomSheet::class.java.name ) } + binding.playAll.setOnClickListener { + if (response.relatedStreams.orEmpty().isEmpty()) return@setOnClickListener + NavigationHelper.navigateVideo( + requireContext(), + response.relatedStreams!!.first().url?.toID(), + playlistId + ) + } + + if (playlistType != PlaylistType.PUBLIC) binding.bookmark.visibility = View.GONE + + binding.bookmark.setOnClickListener { + isBookmarked = !isBookmarked + updateBookmarkRes() + query { + if (!isBookmarked) { + DatabaseHolder.Database.playlistBookmarkDao().deleteById(playlistId!!) + } else { + DatabaseHolder.Database.playlistBookmarkDao().insertAll( + PlaylistBookmark( + playlistId = playlistId!!, + playlistName = response.name, + thumbnailUrl = response.thumbnailUrl, + uploader = response.uploader, + uploaderAvatar = response.uploaderAvatar, + uploaderUrl = response.uploaderUrl + ) + ) + } + } + } + playlistAdapter = PlaylistAdapter( - response.relatedStreams!!.toMutableList(), + response.relatedStreams.orEmpty().toMutableList(), playlistId!!, - isOwner, - requireActivity(), - childFragmentManager + playlistType ) // listen for playlist items to become deleted playlistAdapter!!.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { override fun onChanged() { - binding.videoCount.text = - getString( - R.string.videoCount, - playlistAdapter!!.itemCount.toString() - ) + binding.playlistInfo.text = + binding.playlistInfo.text.split(TextUtils.SEPARATOR).first() + TextUtils.SEPARATOR + getString( + R.string.videoCount, + playlistAdapter!!.itemCount.toString() + ) } }) @@ -130,7 +187,7 @@ class PlaylistFragment : BaseFragment() { /** * listener for swiping to the left or right */ - if (isOwner) { + if (playlistType != PlaylistType.PUBLIC) { val itemTouchCallback = object : ItemTouchHelper.SimpleCallback( 0, ItemTouchHelper.LEFT @@ -148,7 +205,7 @@ class PlaylistFragment : BaseFragment() { direction: Int ) { val position = viewHolder.absoluteAdapterPosition - playlistAdapter!!.removeFromPlaylist(position) + playlistAdapter!!.removeFromPlaylist(requireContext(), position) } } @@ -160,34 +217,27 @@ class PlaylistFragment : BaseFragment() { } private fun fetchNextPage() { - fun run() { - lifecycleScope.launchWhenCreated { - val response = try { - // load locally stored playlists with the auth api - if (isOwner) { - RetrofitInstance.authApi.getPlaylistNextPage( - playlistId!!, - nextPage!! - ) - } else { - RetrofitInstance.api.getPlaylistNextPage( - playlistId!!, - nextPage!! - ) - } - } catch (e: IOException) { - println(e) - Log.e(TAG(), "IOException, you might not have internet connection") - return@launchWhenCreated - } catch (e: HttpException) { - Log.e(TAG(), "HttpException, unexpected response," + e.response()) - return@launchWhenCreated + lifecycleScope.launchWhenCreated { + val response = try { + // load locally stored playlists with the auth api + if (playlistType == PlaylistType.PRIVATE) { + RetrofitInstance.authApi.getPlaylistNextPage( + playlistId!!, + nextPage!! + ) + } else { + RetrofitInstance.api.getPlaylistNextPage( + playlistId!!, + nextPage!! + ) } - nextPage = response.nextpage - playlistAdapter?.updateItems(response.relatedStreams!!) - isLoading = false + } catch (e: Exception) { + Log.e(TAG(), e.toString()) + return@launchWhenCreated } + nextPage = response.nextpage + playlistAdapter?.updateItems(response.relatedStreams!!) + isLoading = false } - run() } } diff --git a/app/src/main/java/com/github/libretube/ui/fragments/SearchFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/SearchFragment.kt index b690af088..a4fe2a8ce 100644 --- a/app/src/main/java/com/github/libretube/ui/fragments/SearchFragment.kt +++ b/app/src/main/java/com/github/libretube/ui/fragments/SearchFragment.kt @@ -7,21 +7,17 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager -import com.github.libretube.R import com.github.libretube.api.RetrofitInstance import com.github.libretube.databinding.FragmentSearchBinding import com.github.libretube.db.DatabaseHolder.Companion.Database import com.github.libretube.extensions.TAG import com.github.libretube.extensions.awaitQuery -import com.github.libretube.models.SearchViewModel import com.github.libretube.ui.activities.MainActivity import com.github.libretube.ui.adapters.SearchHistoryAdapter import com.github.libretube.ui.adapters.SearchSuggestionsAdapter import com.github.libretube.ui.base.BaseFragment -import retrofit2.HttpException -import java.io.IOException +import com.github.libretube.ui.models.SearchViewModel class SearchFragment : BaseFragment() { private lateinit var binding: FragmentSearchBinding @@ -72,12 +68,9 @@ class SearchFragment : BaseFragment() { lifecycleScope.launchWhenCreated { val response = try { RetrofitInstance.api.getSuggestions(query) - } catch (e: IOException) { + } catch (e: Exception) { println(e) - Log.e(TAG(), "IOException, you might not have internet connection") - return@launchWhenCreated - } catch (e: HttpException) { - Log.e(TAG(), "HttpException, unexpected response") + Log.e(TAG(), e.toString()) return@launchWhenCreated } // only load the suggestions if the input field didn't get cleared yet @@ -109,14 +102,4 @@ class SearchFragment : BaseFragment() { binding.historyEmpty.visibility = View.VISIBLE } } - - override fun onStop() { - if (findNavController().currentDestination?.id != R.id.searchResultFragment) { - // remove the search focus - (activity as MainActivity) - .binding.toolbar.menu - .findItem(R.id.action_search).collapseActionView() - } - super.onStop() - } } diff --git a/app/src/main/java/com/github/libretube/ui/fragments/SearchResultFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/SearchResultFragment.kt index 0fcbb0da2..d85d39942 100644 --- a/app/src/main/java/com/github/libretube/ui/fragments/SearchResultFragment.kt +++ b/app/src/main/java/com/github/libretube/ui/fragments/SearchResultFragment.kt @@ -6,7 +6,6 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import com.github.libretube.R import com.github.libretube.api.RetrofitInstance @@ -16,7 +15,6 @@ import com.github.libretube.db.DatabaseHelper import com.github.libretube.db.obj.SearchHistoryItem import com.github.libretube.extensions.TAG import com.github.libretube.extensions.hideKeyboard -import com.github.libretube.ui.activities.MainActivity import com.github.libretube.ui.adapters.SearchAdapter import com.github.libretube.ui.base.BaseFragment import com.github.libretube.util.PreferenceHelper @@ -96,7 +94,7 @@ class SearchResultFragment : BaseFragment() { runOnUiThread { if (response.items?.isNotEmpty() == true) { binding.searchRecycler.layoutManager = LinearLayoutManager(requireContext()) - searchAdapter = SearchAdapter(response.items, childFragmentManager) + searchAdapter = SearchAdapter(response.items) binding.searchRecycler.adapter = searchAdapter } else { binding.searchContainer.visibility = View.GONE @@ -143,14 +141,4 @@ class SearchResultFragment : BaseFragment() { ) } } - - override fun onStop() { - if (findNavController().currentDestination?.id != R.id.searchFragment) { - // remove the search focus - (activity as MainActivity) - .binding.toolbar.menu - .findItem(R.id.action_search).collapseActionView() - } - super.onStop() - } } diff --git a/app/src/main/java/com/github/libretube/ui/fragments/SubscriptionsFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/SubscriptionsFragment.kt index 7af018805..55d1e918b 100644 --- a/app/src/main/java/com/github/libretube/ui/fragments/SubscriptionsFragment.kt +++ b/app/src/main/java/com/github/libretube/ui/fragments/SubscriptionsFragment.kt @@ -12,19 +12,19 @@ import androidx.recyclerview.widget.LinearLayoutManager import com.github.libretube.R import com.github.libretube.constants.PreferenceKeys import com.github.libretube.databinding.FragmentSubscriptionsBinding -import com.github.libretube.models.SubscriptionsViewModel import com.github.libretube.ui.adapters.LegacySubscriptionAdapter import com.github.libretube.ui.adapters.SubscriptionChannelAdapter -import com.github.libretube.ui.adapters.TrendingAdapter +import com.github.libretube.ui.adapters.VideosAdapter import com.github.libretube.ui.base.BaseFragment -import com.github.libretube.ui.views.BottomSheet +import com.github.libretube.ui.models.SubscriptionsViewModel +import com.github.libretube.ui.sheets.BaseBottomSheet import com.github.libretube.util.PreferenceHelper class SubscriptionsFragment : BaseFragment() { private lateinit var binding: FragmentSubscriptionsBinding private val viewModel: SubscriptionsViewModel by activityViewModels() - private var subscriptionAdapter: TrendingAdapter? = null + private var subscriptionAdapter: VideosAdapter? = null private var sortOrder = 0 override fun onCreateView( @@ -48,12 +48,7 @@ class SubscriptionsFragment : BaseFragment() { binding.subProgress.visibility = View.VISIBLE - val grid = PreferenceHelper.getString( - PreferenceKeys.GRID_COLUMNS, - resources.getInteger(R.integer.grid_items).toString() - ) - - binding.subFeed.layoutManager = GridLayoutManager(view.context, grid.toInt()) + binding.subFeed.layoutManager = VideosAdapter.getLayout(requireContext()) if (viewModel.videoFeed.value == null || !loadFeedInBackground) { viewModel.videoFeed.value = null @@ -62,7 +57,10 @@ class SubscriptionsFragment : BaseFragment() { // listen for error responses viewModel.errorResponse.observe(viewLifecycleOwner) { - if (it) Toast.makeText(context, R.string.server_error, Toast.LENGTH_SHORT).show() + if (it) { + Toast.makeText(context, R.string.server_error, Toast.LENGTH_SHORT).show() + viewModel.errorResponse.value = false + } } viewModel.videoFeed.observe(viewLifecycleOwner) { @@ -121,7 +119,7 @@ class SubscriptionsFragment : BaseFragment() { private fun showSortDialog() { val sortOptions = resources.getStringArray(R.array.sortOptions) - val bottomSheet = BottomSheet().apply { + val bottomSheet = BaseBottomSheet().apply { setSimpleItems(sortOptions.toList()) { index -> binding.sortTV.text = sortOptions[index] sortOrder = index @@ -155,7 +153,10 @@ class SubscriptionsFragment : BaseFragment() { if (viewModel.videoFeed.value!!.isEmpty()) View.VISIBLE else View.GONE binding.subProgress.visibility = View.GONE - subscriptionAdapter = TrendingAdapter(sortedFeed, childFragmentManager, false) + subscriptionAdapter = VideosAdapter( + sortedFeed.toMutableList(), + showAllAtOnce = false + ) binding.subFeed.adapter = subscriptionAdapter } diff --git a/app/src/main/java/com/github/libretube/ui/fragments/TrendsFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/TrendsFragment.kt new file mode 100644 index 000000000..317d281f2 --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/fragments/TrendsFragment.kt @@ -0,0 +1,95 @@ +package com.github.libretube.ui.fragments + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.lifecycle.lifecycleScope +import com.github.libretube.R +import com.github.libretube.api.RetrofitInstance +import com.github.libretube.databinding.FragmentTrendsBinding +import com.github.libretube.extensions.TAG +import com.github.libretube.ui.activities.SettingsActivity +import com.github.libretube.ui.adapters.VideosAdapter +import com.github.libretube.ui.base.BaseFragment +import com.github.libretube.util.LocaleHelper +import com.google.android.material.snackbar.Snackbar +import retrofit2.HttpException +import java.io.IOException + +class TrendsFragment : BaseFragment() { + private lateinit var binding: FragmentTrendsBinding + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentTrendsBinding.inflate(layoutInflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + fetchTrending() + binding.homeRefresh.isEnabled = true + binding.homeRefresh.setOnRefreshListener { + fetchTrending() + } + } + + private fun fetchTrending() { + lifecycleScope.launchWhenCreated { + val response = try { + RetrofitInstance.api.getTrending( + LocaleHelper.getTrendingRegion(requireContext()) + ) + } catch (e: IOException) { + println(e) + Log.e(TAG(), "IOException, you might not have internet connection") + Toast.makeText(context, R.string.unknown_error, Toast.LENGTH_SHORT).show() + return@launchWhenCreated + } catch (e: HttpException) { + Log.e(TAG(), "HttpException, unexpected response") + Toast.makeText(context, R.string.server_error, Toast.LENGTH_SHORT).show() + return@launchWhenCreated + } finally { + binding.homeRefresh.isRefreshing = false + } + runOnUiThread { + binding.progressBar.visibility = View.GONE + + // show a [SnackBar] if there are no trending videos available + if (response.isEmpty()) { + Snackbar.make( + binding.root, + R.string.change_region, + Snackbar.LENGTH_LONG + ) + .setAction( + R.string.settings + ) { + startActivity( + Intent( + context, + SettingsActivity::class.java + ) + ) + } + .show() + return@runOnUiThread + } + + binding.recview.adapter = VideosAdapter( + response.toMutableList() + ) + + binding.recview.layoutManager = VideosAdapter.getLayout(requireContext()) + } + } + } +} diff --git a/app/src/main/java/com/github/libretube/ui/fragments/WatchHistoryFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/WatchHistoryFragment.kt index 2a291b0da..a9ae97a9a 100644 --- a/app/src/main/java/com/github/libretube/ui/fragments/WatchHistoryFragment.kt +++ b/app/src/main/java/com/github/libretube/ui/fragments/WatchHistoryFragment.kt @@ -12,6 +12,7 @@ import com.github.libretube.db.DatabaseHolder.Companion.Database import com.github.libretube.extensions.awaitQuery import com.github.libretube.ui.adapters.WatchHistoryAdapter import com.github.libretube.ui.base.BaseFragment +import com.github.libretube.util.ProxyHelper class WatchHistoryFragment : BaseFragment() { private lateinit var binding: FragmentWatchHistoryBinding @@ -34,6 +35,11 @@ class WatchHistoryFragment : BaseFragment() { if (watchHistory.isEmpty()) return + watchHistory.forEach { + it.thumbnailUrl = ProxyHelper.rewriteUrl(it.thumbnailUrl) + it.uploaderAvatar = ProxyHelper.rewriteUrl(it.uploaderAvatar) + } + // reversed order binding.watchHistoryRecView.layoutManager = LinearLayoutManager(requireContext()).apply { reverseLayout = true @@ -41,8 +47,7 @@ class WatchHistoryFragment : BaseFragment() { } val watchHistoryAdapter = WatchHistoryAdapter( - watchHistory.toMutableList(), - childFragmentManager + watchHistory.toMutableList() ) val itemTouchCallback = object : ItemTouchHelper.SimpleCallback( diff --git a/app/src/main/java/com/github/libretube/ui/interfaces/DoubleTapListener.kt b/app/src/main/java/com/github/libretube/ui/interfaces/DoubleTapListener.kt new file mode 100644 index 000000000..24fd9b1eb --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/interfaces/DoubleTapListener.kt @@ -0,0 +1,49 @@ +package com.github.libretube.ui.interfaces + +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.view.View + +abstract class DoubleTapListener : View.OnClickListener { + + private val handler = Handler(Looper.getMainLooper()) + + private var lastClick = 0L + private var lastDoubleClick = 0L + + abstract fun onDoubleClick() + abstract fun onSingleClick() + + override fun onClick(v: View?) { + if (isSecondClick()) { + handler.removeCallbacks(runnable) + lastDoubleClick = elapsedTime() + onDoubleClick() + } else { + if (recentDoubleClick()) return + handler.removeCallbacks(runnable) + handler.postDelayed(runnable, MAX_TIME_DIFF) + lastClick = elapsedTime() + } + } + + private val runnable = Runnable { + if (isSecondClick()) return@Runnable + onSingleClick() + } + + private fun isSecondClick(): Boolean { + return elapsedTime() - lastClick < MAX_TIME_DIFF + } + + private fun recentDoubleClick(): Boolean { + return elapsedTime() - lastDoubleClick < MAX_TIME_DIFF / 2 + } + + fun elapsedTime() = SystemClock.elapsedRealtime() + + companion object { + private const val MAX_TIME_DIFF = 400L + } +} diff --git a/app/src/main/java/com/github/libretube/ui/interfaces/OnlinePlayerOptions.kt b/app/src/main/java/com/github/libretube/ui/interfaces/OnlinePlayerOptions.kt new file mode 100644 index 000000000..d9e37f6e6 --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/interfaces/OnlinePlayerOptions.kt @@ -0,0 +1,9 @@ +package com.github.libretube.ui.interfaces + +interface OnlinePlayerOptions { + fun onCaptionsClicked() + + fun onQualityClicked() + + fun onAudioStreamClicked() +} diff --git a/app/src/main/java/com/github/libretube/ui/interfaces/PlayerOptions.kt b/app/src/main/java/com/github/libretube/ui/interfaces/PlayerOptions.kt new file mode 100644 index 000000000..6b60b4785 --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/interfaces/PlayerOptions.kt @@ -0,0 +1,11 @@ +package com.github.libretube.ui.interfaces + +interface PlayerOptions { + fun onAutoplayClicked() + + fun onPlaybackSpeedClicked() + + fun onResizeModeClicked() + + fun onRepeatModeClicked() +} diff --git a/app/src/main/java/com/github/libretube/models/PlayerViewModel.kt b/app/src/main/java/com/github/libretube/ui/models/PlayerViewModel.kt similarity index 88% rename from app/src/main/java/com/github/libretube/models/PlayerViewModel.kt rename to app/src/main/java/com/github/libretube/ui/models/PlayerViewModel.kt index 14e707513..a42d566a9 100644 --- a/app/src/main/java/com/github/libretube/models/PlayerViewModel.kt +++ b/app/src/main/java/com/github/libretube/ui/models/PlayerViewModel.kt @@ -1,4 +1,4 @@ -package com.github.libretube.models +package com.github.libretube.ui.models import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel diff --git a/app/src/main/java/com/github/libretube/models/PlaylistViewModel.kt b/app/src/main/java/com/github/libretube/ui/models/PlaylistViewModel.kt similarity index 76% rename from app/src/main/java/com/github/libretube/models/PlaylistViewModel.kt rename to app/src/main/java/com/github/libretube/ui/models/PlaylistViewModel.kt index 633286d42..700fc9670 100644 --- a/app/src/main/java/com/github/libretube/models/PlaylistViewModel.kt +++ b/app/src/main/java/com/github/libretube/ui/models/PlaylistViewModel.kt @@ -1,4 +1,4 @@ -package com.github.libretube.models +package com.github.libretube.ui.models import androidx.lifecycle.ViewModel diff --git a/app/src/main/java/com/github/libretube/models/SearchViewModel.kt b/app/src/main/java/com/github/libretube/ui/models/SearchViewModel.kt similarity index 86% rename from app/src/main/java/com/github/libretube/models/SearchViewModel.kt rename to app/src/main/java/com/github/libretube/ui/models/SearchViewModel.kt index c8c46b0ff..86d429fd0 100644 --- a/app/src/main/java/com/github/libretube/models/SearchViewModel.kt +++ b/app/src/main/java/com/github/libretube/ui/models/SearchViewModel.kt @@ -1,4 +1,4 @@ -package com.github.libretube.models +package com.github.libretube.ui.models import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel diff --git a/app/src/main/java/com/github/libretube/models/SubscriptionsViewModel.kt b/app/src/main/java/com/github/libretube/ui/models/SubscriptionsViewModel.kt similarity index 59% rename from app/src/main/java/com/github/libretube/models/SubscriptionsViewModel.kt rename to app/src/main/java/com/github/libretube/ui/models/SubscriptionsViewModel.kt index 0a9518fc4..71297c792 100644 --- a/app/src/main/java/com/github/libretube/models/SubscriptionsViewModel.kt +++ b/app/src/main/java/com/github/libretube/ui/models/SubscriptionsViewModel.kt @@ -1,10 +1,11 @@ -package com.github.libretube.models +package com.github.libretube.ui.models import android.util.Log import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import com.github.libretube.api.RetrofitInstance import com.github.libretube.api.SubscriptionHelper +import com.github.libretube.api.obj.StreamItem +import com.github.libretube.api.obj.Subscription import com.github.libretube.extensions.TAG import com.github.libretube.extensions.toID import com.github.libretube.util.PreferenceHelper @@ -17,26 +18,18 @@ class SubscriptionsViewModel : ViewModel() { value = false } - var videoFeed = MutableLiveData?>().apply { + var videoFeed = MutableLiveData?>().apply { value = null } - var subscriptions = MutableLiveData?>().apply { + var subscriptions = MutableLiveData?>().apply { value = null } fun fetchFeed() { CoroutineScope(Dispatchers.IO).launch { val videoFeed = try { - if (PreferenceHelper.getToken() != "") { - RetrofitInstance.authApi.getFeed( - PreferenceHelper.getToken() - ) - } else { - RetrofitInstance.authApi.getUnauthenticatedFeed( - SubscriptionHelper.getFormattedLocalSubscriptions() - ) - } + SubscriptionHelper.getFeed() } catch (e: Exception) { errorResponse.postValue(true) Log.e(TAG(), e.toString()) @@ -53,15 +46,7 @@ class SubscriptionsViewModel : ViewModel() { fun fetchSubscriptions() { CoroutineScope(Dispatchers.IO).launch { val subscriptions = try { - if (PreferenceHelper.getToken() != "") { - RetrofitInstance.authApi.subscriptions( - PreferenceHelper.getToken() - ) - } else { - RetrofitInstance.authApi.unauthenticatedSubscriptions( - SubscriptionHelper.getFormattedLocalSubscriptions() - ) - } + SubscriptionHelper.getSubscriptions() } catch (e: Exception) { errorResponse.postValue(true) Log.e(TAG(), e.toString()) diff --git a/app/src/main/java/com/github/libretube/ui/preferences/AdvancedSettings.kt b/app/src/main/java/com/github/libretube/ui/preferences/AdvancedSettings.kt index 3480a7f25..6835dd7f5 100644 --- a/app/src/main/java/com/github/libretube/ui/preferences/AdvancedSettings.kt +++ b/app/src/main/java/com/github/libretube/ui/preferences/AdvancedSettings.kt @@ -17,6 +17,8 @@ import com.github.libretube.util.BackupHelper import com.github.libretube.util.ImageHelper import com.github.libretube.util.PreferenceHelper import com.google.android.material.dialog.MaterialAlertDialogBuilder +import java.time.LocalDate +import java.time.LocalTime class AdvancedSettings : BasePreferenceFragment() { @@ -64,7 +66,7 @@ class AdvancedSettings : BasePreferenceFragment() { advancesBackup?.setOnPreferenceClickListener { BackupDialog { backupFile = it - createBackupFile.launch("backup.json") + createBackupFile.launch(getBackupFileName()) } .show(childFragmentManager, null) true @@ -93,4 +95,9 @@ class AdvancedSettings : BasePreferenceFragment() { } .show() } + + private fun getBackupFileName(): String { + val time = LocalTime.now().toString().split(".").firstOrNull() + return "libretube-backup-${LocalDate.now()}-$time.json" + } } diff --git a/app/src/main/java/com/github/libretube/ui/preferences/AppearanceSettings.kt b/app/src/main/java/com/github/libretube/ui/preferences/AppearanceSettings.kt index 799a2d63d..583e3235d 100644 --- a/app/src/main/java/com/github/libretube/ui/preferences/AppearanceSettings.kt +++ b/app/src/main/java/com/github/libretube/ui/preferences/AppearanceSettings.kt @@ -1,21 +1,18 @@ package com.github.libretube.ui.preferences -import android.content.ActivityNotFoundException -import android.content.Intent import android.os.Bundle -import android.provider.Settings -import android.widget.Toast import androidx.preference.ListPreference import androidx.preference.Preference import androidx.preference.SwitchPreferenceCompat import com.github.libretube.R import com.github.libretube.constants.PreferenceKeys import com.github.libretube.ui.activities.SettingsActivity +import com.github.libretube.ui.adapters.IconsSheetAdapter import com.github.libretube.ui.base.BasePreferenceFragment import com.github.libretube.ui.dialogs.NavBarOptionsDialog import com.github.libretube.ui.dialogs.RequireRestartDialog +import com.github.libretube.ui.sheets.IconsBottomSheet import com.github.libretube.util.PreferenceHelper -import com.github.libretube.util.ThemeHelper import com.google.android.material.color.DynamicColors class AppearanceSettings : BasePreferenceFragment() { @@ -47,9 +44,13 @@ class AppearanceSettings : BasePreferenceFragment() { true } - val iconChange = findPreference(PreferenceKeys.APP_ICON) - iconChange?.setOnPreferenceChangeListener { _, newValue -> - ThemeHelper.changeIcon(requireContext(), newValue.toString()) + val changeIcon = findPreference(PreferenceKeys.APP_ICON) + val iconPref = PreferenceHelper.getString(PreferenceKeys.APP_ICON, IconsSheetAdapter.Companion.AppIcon.Default.activityAlias) + IconsSheetAdapter.availableIcons.firstOrNull { it.activityAlias == iconPref }?.let { + changeIcon?.summary = getString(it.nameResource) + } + changeIcon?.setOnPreferenceClickListener { + IconsBottomSheet().show(childFragmentManager) true } @@ -69,27 +70,6 @@ class AppearanceSettings : BasePreferenceFragment() { true } - val systemCaptionStyle = - findPreference(PreferenceKeys.SYSTEM_CAPTION_STYLE) - val captionSettings = findPreference(PreferenceKeys.CAPTION_SETTINGS) - - captionSettings?.isVisible = - PreferenceHelper.getBoolean(PreferenceKeys.SYSTEM_CAPTION_STYLE, true) - systemCaptionStyle?.setOnPreferenceChangeListener { _, newValue -> - captionSettings?.isVisible = newValue as Boolean - true - } - - captionSettings?.setOnPreferenceClickListener { - try { - val captionSettingsIntent = Intent(Settings.ACTION_CAPTIONING_SETTINGS) - startActivity(captionSettingsIntent) - } catch (e: ActivityNotFoundException) { - Toast.makeText(activity, R.string.error, Toast.LENGTH_SHORT).show() - } - true - } - val legacySubscriptionView = findPreference(PreferenceKeys.LEGACY_SUBSCRIPTIONS) val legacySubscriptionColumns = diff --git a/app/src/main/java/com/github/libretube/ui/preferences/HistorySettings.kt b/app/src/main/java/com/github/libretube/ui/preferences/HistorySettings.kt index af4be7d11..45e4f811b 100644 --- a/app/src/main/java/com/github/libretube/ui/preferences/HistorySettings.kt +++ b/app/src/main/java/com/github/libretube/ui/preferences/HistorySettings.kt @@ -44,6 +44,14 @@ class HistorySettings : BasePreferenceFragment() { } true } + + val resetBookmarks = findPreference(PreferenceKeys.CLEAR_BOOKMARKS) + resetBookmarks?.setOnPreferenceClickListener { + showClearDialog(R.string.clear_bookmarks) { + Database.playlistBookmarkDao().deleteAll() + } + true + } } private fun showClearDialog(title: Int, action: () -> Unit) { diff --git a/app/src/main/java/com/github/libretube/ui/preferences/MainSettings.kt b/app/src/main/java/com/github/libretube/ui/preferences/MainSettings.kt index e24018651..f67495781 100644 --- a/app/src/main/java/com/github/libretube/ui/preferences/MainSettings.kt +++ b/app/src/main/java/com/github/libretube/ui/preferences/MainSettings.kt @@ -1,6 +1,8 @@ package com.github.libretube.ui.preferences import android.os.Bundle +import android.widget.Toast +import androidx.annotation.StringRes import androidx.fragment.app.Fragment import androidx.preference.Preference import com.github.libretube.BuildConfig @@ -9,7 +11,6 @@ import com.github.libretube.api.RetrofitInstance import com.github.libretube.ui.activities.SettingsActivity import com.github.libretube.ui.base.BasePreferenceFragment import com.github.libretube.ui.dialogs.UpdateDialog -import com.github.libretube.util.NetworkHelper import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -95,57 +96,41 @@ class MainSettings : BasePreferenceFragment() { // checking for update: yes -> dialog, no -> snackBar update?.setOnPreferenceClickListener { + if (BuildConfig.DEBUG) { + Toast.makeText(context, "Updater is disabled for debug versions!", Toast.LENGTH_SHORT).show() + return@setOnPreferenceClickListener true + } CoroutineScope(Dispatchers.IO).launch { - if (!NetworkHelper.isNetworkAvailable(requireContext())) { - (activity as? SettingsActivity)?.binding?.let { - Snackbar.make( - it.root, - R.string.unknown_error, - Snackbar.LENGTH_SHORT - ) - .show() - } - return@launch - } // check for update val updateInfo = try { RetrofitInstance.externalApi.getUpdateInfo() } catch (e: Exception) { + showSnackBar(R.string.unknown_error) return@launch } - if (updateInfo.name == null) { - // request failed - (activity as? SettingsActivity)?.binding?.let { - Snackbar.make( - it.root, - R.string.unknown_error, - Snackbar.LENGTH_SHORT - ) - .show() - } - } else if (BuildConfig.VERSION_NAME != updateInfo.name) { + + if (BuildConfig.VERSION_NAME != updateInfo.name) { // show the UpdateAvailableDialog if there's an update available - val updateAvailableDialog = UpdateDialog(updateInfo) - updateAvailableDialog.show( + UpdateDialog(updateInfo).show( childFragmentManager, UpdateDialog::class.java.name ) } else { // otherwise show the no update available snackBar - (activity as? SettingsActivity)?.binding?.let { - Snackbar.make( - it.root, - R.string.unknown_error, - Snackbar.LENGTH_SHORT - ) - .show() - } + showSnackBar(R.string.app_uptodate) } } true } } + private fun showSnackBar(@StringRes text: Int) { + (activity as? SettingsActivity)?.binding?.let { + Snackbar.make(it.root, text, Snackbar.LENGTH_SHORT) + .show() + } + } + private fun navigateToSettingsFragment(newFragment: Fragment) { parentFragmentManager.beginTransaction() .replace(R.id.settings, newFragment) diff --git a/app/src/main/java/com/github/libretube/ui/preferences/NotificationSettings.kt b/app/src/main/java/com/github/libretube/ui/preferences/NotificationSettings.kt index 80b87bb87..736e6fbf2 100644 --- a/app/src/main/java/com/github/libretube/ui/preferences/NotificationSettings.kt +++ b/app/src/main/java/com/github/libretube/ui/preferences/NotificationSettings.kt @@ -8,6 +8,7 @@ import com.github.libretube.R import com.github.libretube.constants.PreferenceKeys import com.github.libretube.ui.activities.SettingsActivity import com.github.libretube.ui.base.BasePreferenceFragment +import com.github.libretube.ui.views.TimePickerPreference import com.github.libretube.util.NotificationHelper class NotificationSettings : BasePreferenceFragment() { @@ -41,12 +42,26 @@ class NotificationSettings : BasePreferenceFragment() { updateNotificationPrefs() true } + + val notificationTime = findPreference(PreferenceKeys.NOTIFICATION_TIME_ENABLED) + val notificationStartTime = findPreference(PreferenceKeys.NOTIFICATION_START_TIME) + val notificationEndTime = findPreference(PreferenceKeys.NOTIFICATION_END_TIME) + listOf(notificationStartTime, notificationEndTime).forEach { + it?.isEnabled = notificationTime?.isChecked == true + } + notificationTime?.setOnPreferenceChangeListener { _, newValue -> + listOf(notificationStartTime, notificationEndTime).forEach { + it?.isEnabled = newValue as Boolean + } + true + } } private fun updateNotificationPrefs() { // replace the previous queued work request - NotificationHelper(requireContext()) + NotificationHelper .enqueueWork( + context = requireContext(), existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.REPLACE ) } diff --git a/app/src/main/java/com/github/libretube/ui/preferences/PlayerSettings.kt b/app/src/main/java/com/github/libretube/ui/preferences/PlayerSettings.kt index edf80a2de..75d4d9f40 100644 --- a/app/src/main/java/com/github/libretube/ui/preferences/PlayerSettings.kt +++ b/app/src/main/java/com/github/libretube/ui/preferences/PlayerSettings.kt @@ -1,6 +1,10 @@ package com.github.libretube.ui.preferences +import android.content.ActivityNotFoundException +import android.content.Intent import android.os.Bundle +import android.provider.Settings +import android.widget.Toast import androidx.preference.ListPreference import androidx.preference.Preference import androidx.preference.SwitchPreferenceCompat @@ -37,10 +41,31 @@ class PlayerSettings : BasePreferenceFragment() { val defaultSubtitle = findPreference(PreferenceKeys.DEFAULT_SUBTITLE) defaultSubtitle?.let { setupSubtitlePref(it) } + + val systemCaptionStyle = + findPreference(PreferenceKeys.SYSTEM_CAPTION_STYLE) + val captionSettings = findPreference(PreferenceKeys.CAPTION_SETTINGS) + + captionSettings?.isVisible = + PreferenceHelper.getBoolean(PreferenceKeys.SYSTEM_CAPTION_STYLE, true) + systemCaptionStyle?.setOnPreferenceChangeListener { _, newValue -> + captionSettings?.isVisible = newValue as Boolean + true + } + + captionSettings?.setOnPreferenceClickListener { + try { + val captionSettingsIntent = Intent(Settings.ACTION_CAPTIONING_SETTINGS) + startActivity(captionSettingsIntent) + } catch (e: ActivityNotFoundException) { + Toast.makeText(activity, R.string.error, Toast.LENGTH_SHORT).show() + } + true + } } private fun setupSubtitlePref(preference: ListPreference) { - val locales = LocaleHelper.getAvailableLocales() + val locales = LocaleHelper.getAvailableLocales().sortedBy { it.name } val localeNames = locales.map { it.name } .toMutableList() localeNames.add(0, requireContext().getString(R.string.none)) diff --git a/app/src/main/java/com/github/libretube/ui/views/BottomSheet.kt b/app/src/main/java/com/github/libretube/ui/sheets/BaseBottomSheet.kt similarity index 72% rename from app/src/main/java/com/github/libretube/ui/views/BottomSheet.kt rename to app/src/main/java/com/github/libretube/ui/sheets/BaseBottomSheet.kt index 3fd8268cf..635620be1 100644 --- a/app/src/main/java/com/github/libretube/ui/views/BottomSheet.kt +++ b/app/src/main/java/com/github/libretube/ui/sheets/BaseBottomSheet.kt @@ -1,17 +1,15 @@ -package com.github.libretube.ui.views +package com.github.libretube.ui.sheets import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.fragment.app.FragmentManager import androidx.recyclerview.widget.LinearLayoutManager import com.github.libretube.databinding.BottomSheetBinding import com.github.libretube.obj.BottomSheetItem import com.github.libretube.ui.adapters.BottomSheetAdapter -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -open class BottomSheet : BottomSheetDialogFragment() { +open class BaseBottomSheet : ExpandedBottomSheet() { private lateinit var items: List private lateinit var listener: (index: Int) -> Unit private lateinit var binding: BottomSheetBinding @@ -32,27 +30,22 @@ open class BottomSheet : BottomSheetDialogFragment() { binding.optionsRecycler.adapter = BottomSheetAdapter(items, listener) } - fun setItems(items: List, listener: (index: Int) -> Unit) = apply { + fun setItems(items: List, listener: ((index: Int) -> Unit)?) = apply { this.items = items this.listener = { index -> - listener.invoke(index) + listener?.invoke(index) dialog?.dismiss() } } - fun setSimpleItems(titles: List, listener: (index: Int) -> Unit) = apply { + fun setSimpleItems(titles: List, listener: ((index: Int) -> Unit)?) = apply { this.items = titles.map { BottomSheetItem(it) } this.listener = { index -> - listener.invoke(index) + listener?.invoke(index) dialog?.dismiss() } } - fun show(fragmentManager: FragmentManager) = show( - fragmentManager, - null - ) - override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { super.onPictureInPictureModeChanged(isInPictureInPictureMode) dialog?.dismiss() diff --git a/app/src/main/java/com/github/libretube/ui/sheets/ExpandedBottomSheet.kt b/app/src/main/java/com/github/libretube/ui/sheets/ExpandedBottomSheet.kt new file mode 100644 index 000000000..f203460b1 --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/sheets/ExpandedBottomSheet.kt @@ -0,0 +1,36 @@ +package com.github.libretube.ui.sheets + +import android.app.Dialog +import android.content.res.Configuration +import android.os.Bundle +import android.view.View +import android.widget.FrameLayout +import androidx.fragment.app.FragmentManager +import com.google.android.material.R +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +open class ExpandedBottomSheet : BottomSheetDialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog = super.onCreateDialog(savedInstanceState) as BottomSheetDialog + + if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) return dialog + + dialog.setOnShowListener { + (it as BottomSheetDialog).let { d -> + (d.findViewById(R.id.design_bottom_sheet) as FrameLayout?)?.let { + BottomSheetBehavior.from(it).state = + BottomSheetBehavior.STATE_EXPANDED + } + } + } + + return dialog + } + + fun show(fragmentManager: FragmentManager) = show( + fragmentManager, + null + ) +} diff --git a/app/src/main/java/com/github/libretube/ui/sheets/IconsBottomSheet.kt b/app/src/main/java/com/github/libretube/ui/sheets/IconsBottomSheet.kt new file mode 100644 index 000000000..57f9a0656 --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/sheets/IconsBottomSheet.kt @@ -0,0 +1,27 @@ +package com.github.libretube.ui.sheets + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.GridLayoutManager +import com.github.libretube.databinding.BottomSheetBinding +import com.github.libretube.ui.adapters.IconsSheetAdapter + +class IconsBottomSheet : ExpandedBottomSheet() { + private lateinit var binding: BottomSheetBinding + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = BottomSheetBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding.optionsRecycler.layoutManager = GridLayoutManager(context, 3) + binding.optionsRecycler.adapter = IconsSheetAdapter() + } +} diff --git a/app/src/main/java/com/github/libretube/ui/sheets/PlaybackSpeedSheet.kt b/app/src/main/java/com/github/libretube/ui/sheets/PlaybackSpeedSheet.kt index 43f28e6b2..dc0d670b3 100644 --- a/app/src/main/java/com/github/libretube/ui/sheets/PlaybackSpeedSheet.kt +++ b/app/src/main/java/com/github/libretube/ui/sheets/PlaybackSpeedSheet.kt @@ -4,15 +4,14 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.fragment.app.FragmentManager import com.github.libretube.databinding.PlaybackBottomSheetBinding +import com.github.libretube.extensions.round import com.google.android.exoplayer2.PlaybackParameters import com.google.android.exoplayer2.Player -import com.google.android.material.bottomsheet.BottomSheetDialogFragment class PlaybackSpeedSheet( private val player: Player -) : BottomSheetDialogFragment() { +) : ExpandedBottomSheet() { private lateinit var binding: PlaybackBottomSheetBinding override fun onCreateView( @@ -30,24 +29,29 @@ class PlaybackSpeedSheet( binding.speed.value = player.playbackParameters.speed binding.pitch.value = player.playbackParameters.pitch - binding.speed.addOnChangeListener { _, value, _ -> - onChange(value, binding.pitch.value) + binding.speed.addOnChangeListener { _, _, _ -> + onChange() } - binding.pitch.addOnChangeListener { _, value, _ -> - onChange(binding.speed.value, value) + binding.pitch.addOnChangeListener { _, _, _ -> + onChange() + } + + binding.resetSpeed.setOnClickListener { + binding.speed.value = 1f + onChange() + } + + binding.resetPitch.setOnClickListener { + binding.pitch.value = 1f + onChange() } } - private fun onChange(speed: Float, pitch: Float) { + private fun onChange() { player.playbackParameters = PlaybackParameters( - speed, - pitch + binding.speed.value.round(2), + binding.pitch.value.round(2) ) } - - fun show(fragmentManager: FragmentManager) = show( - fragmentManager, - null - ) } diff --git a/app/src/main/java/com/github/libretube/ui/sheets/PlayingQueueSheet.kt b/app/src/main/java/com/github/libretube/ui/sheets/PlayingQueueSheet.kt new file mode 100644 index 000000000..8a5e024fd --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/sheets/PlayingQueueSheet.kt @@ -0,0 +1,110 @@ +package com.github.libretube.ui.sheets + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.github.libretube.databinding.QueueBottomSheetBinding +import com.github.libretube.ui.adapters.PlayingQueueAdapter +import com.github.libretube.util.PlayingQueue + +class PlayingQueueSheet : ExpandedBottomSheet() { + private lateinit var binding: QueueBottomSheetBinding + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = QueueBottomSheetBinding.inflate(layoutInflater) + return binding.root + } + + @SuppressLint("NotifyDataSetChanged") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.optionsRecycler.layoutManager = LinearLayoutManager(context) + val adapter = PlayingQueueAdapter() + binding.optionsRecycler.adapter = adapter + + binding.shuffle.setOnClickListener { + var streams = PlayingQueue.getStreams() + val currentIndex = PlayingQueue.currentIndex() + val current = streams[currentIndex] + + streams = streams.shuffled().toMutableList() + streams.remove(current) + streams.add(currentIndex, current) + PlayingQueue.setStreams(streams) + + adapter.notifyDataSetChanged() + } + + binding.clear.setOnClickListener { + val currentIndex = PlayingQueue.currentIndex() + + val streams = PlayingQueue.getStreams().filterIndexed { position, _ -> + position <= currentIndex + } + + PlayingQueue.setStreams(streams) + adapter.notifyDataSetChanged() + } + + binding.reverse.setOnClickListener { + PlayingQueue.setStreams(PlayingQueue.getStreams().reversed()) + adapter.notifyDataSetChanged() + } + + binding.repeat.setOnClickListener { + PlayingQueue.repeatQueue = !PlayingQueue.repeatQueue + it.alpha = if (PlayingQueue.repeatQueue) 1f else 0.5f + } + + binding.repeat.alpha = if (PlayingQueue.repeatQueue) 1f else 0.5f + + binding.bottomControls.setOnClickListener { + dialog?.dismiss() + } + + val callback = object : ItemTouchHelper.SimpleCallback( + ItemTouchHelper.UP or ItemTouchHelper.DOWN, + ItemTouchHelper.LEFT + ) { + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + val from = viewHolder.absoluteAdapterPosition + val to = target.absoluteAdapterPosition + + val currentPosition = PlayingQueue.currentIndex() + if (to <= currentPosition) return false + + adapter.notifyItemMoved(from, to) + PlayingQueue.move(from, to) + return true + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + val position = viewHolder.absoluteAdapterPosition + if (position == PlayingQueue.currentIndex()) { + adapter.notifyItemChanged(position) + return + } + PlayingQueue.remove(position) + adapter.notifyItemRemoved(position) + adapter.notifyItemRangeChanged(position, adapter.itemCount) + } + } + + val itemTouchHelper = ItemTouchHelper(callback) + itemTouchHelper.attachToRecyclerView(binding.optionsRecycler) + } +} diff --git a/app/src/main/java/com/github/libretube/ui/sheets/PlaylistOptionsBottomSheet.kt b/app/src/main/java/com/github/libretube/ui/sheets/PlaylistOptionsBottomSheet.kt index a5d81f435..3568eafbc 100644 --- a/app/src/main/java/com/github/libretube/ui/sheets/PlaylistOptionsBottomSheet.kt +++ b/app/src/main/java/com/github/libretube/ui/sheets/PlaylistOptionsBottomSheet.kt @@ -2,17 +2,19 @@ package com.github.libretube.ui.sheets import android.os.Bundle import android.text.InputType -import android.util.Log import android.widget.Toast import com.github.libretube.R +import com.github.libretube.api.PlaylistsHelper import com.github.libretube.api.RetrofitInstance import com.github.libretube.api.obj.PlaylistId -import com.github.libretube.constants.ShareObjectType import com.github.libretube.databinding.DialogTextPreferenceBinding -import com.github.libretube.extensions.TAG +import com.github.libretube.enums.PlaylistType +import com.github.libretube.enums.ShareObjectType import com.github.libretube.extensions.toID +import com.github.libretube.extensions.toastFromMainThread +import com.github.libretube.obj.ShareData +import com.github.libretube.ui.dialogs.DeletePlaylistDialog import com.github.libretube.ui.dialogs.ShareDialog -import com.github.libretube.ui.views.BottomSheet import com.github.libretube.util.BackgroundHelper import com.github.libretube.util.PreferenceHelper import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -25,22 +27,22 @@ import java.io.IOException class PlaylistOptionsBottomSheet( private val playlistId: String, - private val isOwner: Boolean -) : BottomSheet() { - + playlistName: String, + private val playlistType: PlaylistType +) : BaseBottomSheet() { + private val shareData = ShareData(currentPlaylist = playlistName) override fun onCreate(savedInstanceState: Bundle?) { // options for the dialog - var optionsList = listOf( - context?.getString(R.string.playOnBackground)!!, - context?.getString(R.string.clonePlaylist)!!, - context?.getString(R.string.share)!! + val optionsList = mutableListOf( + context?.getString(R.string.playOnBackground)!! ) - if (isOwner) { - optionsList = optionsList + - context?.getString(R.string.renamePlaylist)!! + - context?.getString(R.string.deletePlaylist)!! - - context?.getString(R.string.clonePlaylist)!! + if (playlistType == PlaylistType.PUBLIC) { + optionsList.add(context?.getString(R.string.share)!!) + optionsList.add(context?.getString(R.string.clonePlaylist)!!) + } else { + optionsList.add(context?.getString(R.string.renamePlaylist)!!) + optionsList.add(context?.getString(R.string.deletePlaylist)!!) } setSimpleItems(optionsList) { which -> @@ -49,7 +51,7 @@ class PlaylistOptionsBottomSheet( context?.getString(R.string.playOnBackground) -> { runBlocking { val playlist = - if (isOwner) { + if (playlistType == PlaylistType.PRIVATE) { RetrofitInstance.authApi.getPlaylist(playlistId) } else { RetrofitInstance.api.getPlaylist(playlistId) @@ -76,14 +78,13 @@ class PlaylistOptionsBottomSheet( } // share the playlist context?.getString(R.string.share) -> { - val shareDialog = ShareDialog(playlistId, ShareObjectType.PLAYLIST) + val shareDialog = ShareDialog(playlistId, ShareObjectType.PLAYLIST, shareData) // using parentFragmentManager, childFragmentManager doesn't work here shareDialog.show(parentFragmentManager, ShareDialog::class.java.name) } context?.getString(R.string.deletePlaylist) -> { - deletePlaylist( - playlistId - ) + DeletePlaylistDialog(playlistId, playlistType) + .show(parentFragmentManager, null) } context?.getString(R.string.renamePlaylist) -> { val binding = DialogTextPreferenceBinding.inflate(layoutInflater) @@ -102,7 +103,13 @@ class PlaylistOptionsBottomSheet( ).show() return@setPositiveButton } - renamePlaylist(playlistId, binding.input.text.toString()) + CoroutineScope(Dispatchers.IO).launch { + try { + PlaylistsHelper.renamePlaylist(playlistId, binding.input.text.toString()) + } catch (e: Exception) { + return@launch + } + } } .setNegativeButton(R.string.cancel, null) .show() @@ -113,6 +120,7 @@ class PlaylistOptionsBottomSheet( } private fun importPlaylist(token: String, playlistId: String) { + val appContext = context?.applicationContext CoroutineScope(Dispatchers.IO).launch { val response = try { RetrofitInstance.authApi.importPlaylist( @@ -125,36 +133,7 @@ class PlaylistOptionsBottomSheet( } catch (e: HttpException) { return@launch } - Log.e(TAG(), response.toString()) - } - } - - private fun renamePlaylist(id: String, newName: String) { - CoroutineScope(Dispatchers.IO).launch { - try { - RetrofitInstance.authApi.renamePlaylist( - PreferenceHelper.getToken(), - PlaylistId( - playlistId = id, - newName = newName - ) - ) - } catch (e: Exception) { - return@launch - } - } - } - - private fun deletePlaylist(id: String) { - CoroutineScope(Dispatchers.IO).launch { - try { - RetrofitInstance.authApi.deletePlaylist( - PreferenceHelper.getToken(), - PlaylistId(id) - ) - } catch (e: Exception) { - return@launch - } + appContext?.toastFromMainThread(if (response.playlistId != null) R.string.playlistCloned else R.string.server_error) } } } diff --git a/app/src/main/java/com/github/libretube/ui/sheets/VideoOptionsBottomSheet.kt b/app/src/main/java/com/github/libretube/ui/sheets/VideoOptionsBottomSheet.kt index f174e2a33..354df30bb 100644 --- a/app/src/main/java/com/github/libretube/ui/sheets/VideoOptionsBottomSheet.kt +++ b/app/src/main/java/com/github/libretube/ui/sheets/VideoOptionsBottomSheet.kt @@ -1,17 +1,19 @@ package com.github.libretube.ui.sheets import android.os.Bundle -import android.widget.Toast import com.github.libretube.R -import com.github.libretube.constants.IntentData -import com.github.libretube.constants.ShareObjectType +import com.github.libretube.api.RetrofitInstance +import com.github.libretube.enums.ShareObjectType +import com.github.libretube.extensions.toStreamItem +import com.github.libretube.obj.ShareData import com.github.libretube.ui.dialogs.AddToPlaylistDialog import com.github.libretube.ui.dialogs.DownloadDialog import com.github.libretube.ui.dialogs.ShareDialog -import com.github.libretube.ui.views.BottomSheet import com.github.libretube.util.BackgroundHelper import com.github.libretube.util.PlayingQueue -import com.github.libretube.util.PreferenceHelper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch /** * Dialog with different options for a selected video. @@ -19,9 +21,10 @@ import com.github.libretube.util.PreferenceHelper * Needs the [videoId] to load the content from the right video. */ class VideoOptionsBottomSheet( - private val videoId: String -) : BottomSheet() { - + private val videoId: String, + private val videoName: String +) : BaseBottomSheet() { + private val shareData = ShareData(currentVideo = videoName) override fun onCreate(savedInstanceState: Bundle?) { // List that stores the different menu options. In the future could be add more options here. val optionsList = mutableListOf( @@ -31,14 +34,6 @@ class VideoOptionsBottomSheet( context?.getString(R.string.share)!! ) - // remove the add to playlist option if not logged in - if (PreferenceHelper.getToken() == "") { - optionsList.remove( - context?.getString(R.string.addToPlaylist) - - ) - } - /** * Check whether the player is running and add queue options */ @@ -55,34 +50,43 @@ class VideoOptionsBottomSheet( } // Add Video to Playlist Dialog context?.getString(R.string.addToPlaylist) -> { - val token = PreferenceHelper.getToken() - if (token != "") { - val newFragment = AddToPlaylistDialog() - val bundle = Bundle() - bundle.putString(IntentData.videoId, videoId) - newFragment.arguments = bundle - newFragment.show( - parentFragmentManager, - AddToPlaylistDialog::class.java.name - ) - } else { - Toast.makeText(context, R.string.login_first, Toast.LENGTH_SHORT).show() - } + AddToPlaylistDialog(videoId).show( + parentFragmentManager, + AddToPlaylistDialog::class.java.name + ) } context?.getString(R.string.download) -> { val downloadDialog = DownloadDialog(videoId) downloadDialog.show(parentFragmentManager, DownloadDialog::class.java.name) } context?.getString(R.string.share) -> { - val shareDialog = ShareDialog(videoId, ShareObjectType.VIDEO) + val shareDialog = ShareDialog(videoId, ShareObjectType.VIDEO, shareData) // using parentFragmentManager is important here shareDialog.show(parentFragmentManager, ShareDialog::class.java.name) } context?.getString(R.string.play_next) -> { - PlayingQueue.addAsNext(videoId) + CoroutineScope(Dispatchers.IO).launch { + try { + PlayingQueue.addAsNext( + RetrofitInstance.api.getStreams(videoId) + .toStreamItem(videoId) + ) + } catch (e: Exception) { + e.printStackTrace() + } + } } context?.getString(R.string.add_to_queue) -> { - PlayingQueue.add(videoId) + CoroutineScope(Dispatchers.IO).launch { + try { + PlayingQueue.add( + RetrofitInstance.api.getStreams(videoId) + .toStreamItem(videoId) + ) + } catch (e: Exception) { + e.printStackTrace() + } + } } } } diff --git a/app/src/main/java/com/github/libretube/ui/tools/BreakReminder.kt b/app/src/main/java/com/github/libretube/ui/tools/BreakReminder.kt new file mode 100644 index 000000000..7e38e059f --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/tools/BreakReminder.kt @@ -0,0 +1,55 @@ +package com.github.libretube.ui.tools + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.widget.Toast +import com.github.libretube.R +import com.github.libretube.constants.PreferenceKeys +import com.github.libretube.util.PreferenceHelper +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +object BreakReminder { + /** + * Show a break reminder when watched too long + */ + fun setupBreakReminder(context: Context) { + if (!PreferenceHelper.getBoolean( + PreferenceKeys.BREAK_REMINDER_TOGGLE, + false + ) + ) { + return + } + val breakReminderPref = PreferenceHelper.getString( + PreferenceKeys.BREAK_REMINDER, + "0" + ) + if (!breakReminderPref.all { Character.isDigit(it) } || + breakReminderPref == "" || breakReminderPref == "0" + ) { + return + } + Handler(Looper.getMainLooper()).postDelayed( + { + try { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.take_a_break) + .setMessage( + context.getString( + R.string.already_spent_time, + breakReminderPref + ) + ) + .setPositiveButton(R.string.okay, null) + .show() + } catch (e: Exception) { + runCatching { + Toast.makeText(context, R.string.take_a_break, Toast.LENGTH_LONG).show() + } + } + }, + breakReminderPref.toLong() * 60 * 1000 + ) + } +} diff --git a/app/src/main/java/com/github/libretube/ui/viewholders/SubscriptionViewHolder.kt b/app/src/main/java/com/github/libretube/ui/viewholders/IconsSheetViewHolder.kt similarity index 52% rename from app/src/main/java/com/github/libretube/ui/viewholders/SubscriptionViewHolder.kt rename to app/src/main/java/com/github/libretube/ui/viewholders/IconsSheetViewHolder.kt index 0e2d369a4..78d21a6f8 100644 --- a/app/src/main/java/com/github/libretube/ui/viewholders/SubscriptionViewHolder.kt +++ b/app/src/main/java/com/github/libretube/ui/viewholders/IconsSheetViewHolder.kt @@ -1,8 +1,8 @@ package com.github.libretube.ui.viewholders import androidx.recyclerview.widget.RecyclerView -import com.github.libretube.databinding.TrendingRowBinding +import com.github.libretube.databinding.AppIconItemBinding -class SubscriptionViewHolder( - val binding: TrendingRowBinding +class IconsSheetViewHolder( + val binding: AppIconItemBinding ) : RecyclerView.ViewHolder(binding.root) diff --git a/app/src/main/java/com/github/libretube/ui/viewholders/ChannelViewHolder.kt b/app/src/main/java/com/github/libretube/ui/viewholders/PlayingQueueViewHolder.kt similarity index 53% rename from app/src/main/java/com/github/libretube/ui/viewholders/ChannelViewHolder.kt rename to app/src/main/java/com/github/libretube/ui/viewholders/PlayingQueueViewHolder.kt index 12f274d46..9fe6f200d 100644 --- a/app/src/main/java/com/github/libretube/ui/viewholders/ChannelViewHolder.kt +++ b/app/src/main/java/com/github/libretube/ui/viewholders/PlayingQueueViewHolder.kt @@ -1,8 +1,8 @@ package com.github.libretube.ui.viewholders import androidx.recyclerview.widget.RecyclerView -import com.github.libretube.databinding.VideoRowBinding +import com.github.libretube.databinding.QueueRowBinding -class ChannelViewHolder( - val binding: VideoRowBinding +class PlayingQueueViewHolder( + val binding: QueueRowBinding ) : RecyclerView.ViewHolder(binding.root) diff --git a/app/src/main/java/com/github/libretube/ui/viewholders/PlaylistBookmarkViewHolder.kt b/app/src/main/java/com/github/libretube/ui/viewholders/PlaylistBookmarkViewHolder.kt new file mode 100644 index 000000000..fc768ea6a --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/viewholders/PlaylistBookmarkViewHolder.kt @@ -0,0 +1,8 @@ +package com.github.libretube.ui.viewholders + +import androidx.recyclerview.widget.RecyclerView +import com.github.libretube.databinding.PlaylistBookmarkRowBinding + +class PlaylistBookmarkViewHolder( + val binding: PlaylistBookmarkRowBinding +) : RecyclerView.ViewHolder(binding.root) diff --git a/app/src/main/java/com/github/libretube/ui/viewholders/SearchViewHolder.kt b/app/src/main/java/com/github/libretube/ui/viewholders/SearchViewHolder.kt index 760f5aa52..1d4e2bbd9 100644 --- a/app/src/main/java/com/github/libretube/ui/viewholders/SearchViewHolder.kt +++ b/app/src/main/java/com/github/libretube/ui/viewholders/SearchViewHolder.kt @@ -2,13 +2,13 @@ package com.github.libretube.ui.viewholders import androidx.recyclerview.widget.RecyclerView import com.github.libretube.databinding.ChannelRowBinding -import com.github.libretube.databinding.PlaylistSearchRowBinding +import com.github.libretube.databinding.PlaylistsRowBinding import com.github.libretube.databinding.VideoRowBinding class SearchViewHolder : RecyclerView.ViewHolder { var videoRowBinding: VideoRowBinding? = null var channelRowBinding: ChannelRowBinding? = null - var playlistRowBinding: PlaylistSearchRowBinding? = null + var playlistRowBinding: PlaylistsRowBinding? = null constructor(binding: VideoRowBinding) : super(binding.root) { videoRowBinding = binding @@ -18,7 +18,7 @@ class SearchViewHolder : RecyclerView.ViewHolder { channelRowBinding = binding } - constructor(binding: PlaylistSearchRowBinding) : super(binding.root) { + constructor(binding: PlaylistsRowBinding) : super(binding.root) { playlistRowBinding = binding } } diff --git a/app/src/main/java/com/github/libretube/ui/viewholders/VideosViewHolder.kt b/app/src/main/java/com/github/libretube/ui/viewholders/VideosViewHolder.kt new file mode 100644 index 000000000..0b5d293c8 --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/viewholders/VideosViewHolder.kt @@ -0,0 +1,18 @@ +package com.github.libretube.ui.viewholders + +import androidx.recyclerview.widget.RecyclerView +import com.github.libretube.databinding.TrendingRowBinding +import com.github.libretube.databinding.VideoRowBinding + +class VideosViewHolder : RecyclerView.ViewHolder { + var trendingRowBinding: TrendingRowBinding? = null + var videoRowBinding: VideoRowBinding? = null + + constructor(binding: TrendingRowBinding) : super(binding.root) { + trendingRowBinding = binding + } + + constructor(binding: VideoRowBinding) : super(binding.root) { + videoRowBinding = binding + } +} diff --git a/app/src/main/java/com/github/libretube/ui/views/CustomExoPlayerView.kt b/app/src/main/java/com/github/libretube/ui/views/CustomExoPlayerView.kt index f1a887f41..dd51ca16f 100644 --- a/app/src/main/java/com/github/libretube/ui/views/CustomExoPlayerView.kt +++ b/app/src/main/java/com/github/libretube/ui/views/CustomExoPlayerView.kt @@ -8,19 +8,22 @@ import android.os.Looper import android.util.AttributeSet import android.view.MotionEvent import android.view.View -import androidx.fragment.app.FragmentManager import com.github.libretube.R import com.github.libretube.databinding.DoubleTapOverlayBinding import com.github.libretube.databinding.ExoStyledPlayerControlViewBinding import com.github.libretube.extensions.toDp -import com.github.libretube.models.interfaces.DoubleTapInterface -import com.github.libretube.models.interfaces.PlayerOptionsInterface import com.github.libretube.obj.BottomSheetItem import com.github.libretube.ui.activities.MainActivity +import com.github.libretube.ui.base.BaseActivity +import com.github.libretube.ui.interfaces.DoubleTapListener +import com.github.libretube.ui.interfaces.OnlinePlayerOptions +import com.github.libretube.ui.interfaces.PlayerOptions +import com.github.libretube.ui.sheets.BaseBottomSheet import com.github.libretube.ui.sheets.PlaybackSpeedSheet -import com.github.libretube.util.DoubleTapListener import com.github.libretube.util.PlayerHelper +import com.github.libretube.util.PlayingQueue import com.google.android.exoplayer2.PlaybackParameters +import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.trackselection.TrackSelector import com.google.android.exoplayer2.ui.AspectRatioFrameLayout import com.google.android.exoplayer2.ui.StyledPlayerView @@ -30,16 +33,14 @@ import com.google.android.exoplayer2.util.RepeatModeUtil internal class CustomExoPlayerView( context: Context, attributeSet: AttributeSet? = null -) : StyledPlayerView(context, attributeSet) { +) : StyledPlayerView(context, attributeSet), PlayerOptions { val binding: ExoStyledPlayerControlViewBinding = ExoStyledPlayerControlViewBinding.bind(this) private var doubleTapOverlayBinding: DoubleTapOverlayBinding? = null /** * Objects from the parent fragment */ - private var doubleTapListener: DoubleTapInterface? = null - private var playerOptionsInterface: PlayerOptionsInterface? = null - private lateinit var childFragmentManager: FragmentManager + private var playerOptionsInterface: OnlinePlayerOptions? = null private var trackSelector: TrackSelector? = null private val runnableHandler = Handler(Looper.getMainLooper()) @@ -53,16 +54,34 @@ internal class CustomExoPlayerView( * Preferences */ var autoplayEnabled = PlayerHelper.autoPlayEnabled + private var doubleTapAllowed = true private var resizeModePref = PlayerHelper.resizeModePref + private val supportFragmentManager + get() = (context as BaseActivity).supportFragmentManager + private fun toggleController() { if (isControllerFullyVisible) hideController() else showController() } private val doubleTouchListener = object : DoubleTapListener() { override fun onDoubleClick() { - doubleTapListener?.onEvent(xPos) + if (!doubleTapAllowed) return + val eventPositionPercentageX = xPos / width + when { + eventPositionPercentageX < 0.4 -> rewind() + eventPositionPercentageX > 0.6 -> forward() + else -> { + player?.let { player -> + if (player.isPlaying) { + player.pause() + } else { + player.play() + } + } + } + } } override fun onSingleClick() { @@ -71,12 +90,10 @@ internal class CustomExoPlayerView( } fun initialize( - childFragmentManager: FragmentManager, - playerViewInterface: PlayerOptionsInterface?, + playerViewInterface: OnlinePlayerOptions?, doubleTapOverlayBinding: DoubleTapOverlayBinding, trackSelector: TrackSelector? ) { - this.childFragmentManager = childFragmentManager this.playerOptionsInterface = playerViewInterface this.doubleTapOverlayBinding = doubleTapOverlayBinding this.trackSelector = trackSelector @@ -136,79 +153,101 @@ internal class CustomExoPlayerView( private fun initializeAdvancedOptions(context: Context) { binding.toggleOptions.setOnClickListener { - val bottomSheetFragment = BottomSheet().apply { - val items = mutableListOf( - BottomSheetItem( - context.getString(R.string.player_autoplay), - R.drawable.ic_play, + val items = mutableListOf( + BottomSheetItem( + context.getString(R.string.player_autoplay), + R.drawable.ic_play, + { if (autoplayEnabled) { context.getString(R.string.enabled) } else { context.getString(R.string.disabled) } - ), - BottomSheetItem( - context.getString(R.string.repeat_mode), - R.drawable.ic_repeat, + } + ) { + onAutoplayClicked() + }, + BottomSheetItem( + context.getString(R.string.repeat_mode), + R.drawable.ic_repeat, + { if (player?.repeatMode == RepeatModeUtil.REPEAT_TOGGLE_MODE_NONE) { context.getString(R.string.repeat_mode_none) } else { context.getString(R.string.repeat_mode_current) } - ), - BottomSheetItem( - context.getString(R.string.player_resize_mode), - R.drawable.ic_aspect_ratio, + } + ) { + onRepeatModeClicked() + }, + BottomSheetItem( + context.getString(R.string.player_resize_mode), + R.drawable.ic_aspect_ratio, + { when (resizeMode) { AspectRatioFrameLayout.RESIZE_MODE_FIT -> context.getString(R.string.resize_mode_fit) AspectRatioFrameLayout.RESIZE_MODE_FILL -> context.getString(R.string.resize_mode_fill) else -> context.getString(R.string.resize_mode_zoom) } - ), - BottomSheetItem( - context.getString(R.string.playback_speed), - R.drawable.ic_speed, + } + ) { + onResizeModeClicked() + }, + BottomSheetItem( + context.getString(R.string.playback_speed), + R.drawable.ic_speed, + { "${ player?.playbackParameters?.speed .toString() .replace(".0", "") }x" - ) - ) + } + ) { + onPlaybackSpeedClicked() + } + ) - if (playerOptionsInterface != null) { - items.add( - BottomSheetItem( - context.getString(R.string.quality), - R.drawable.ic_hd, - "${player?.videoSize?.height}p" - ) - ) - items.add( - BottomSheetItem( - context.getString(R.string.captions), - R.drawable.ic_caption, + if (playerOptionsInterface != null) { + items.add( + BottomSheetItem( + context.getString(R.string.quality), + R.drawable.ic_hd, + { "${player?.videoSize?.height}p" } + ) { + playerOptionsInterface?.onQualityClicked() + } + ) + items.add( + BottomSheetItem( + context.getString(R.string.audio_track), + R.drawable.ic_audio, + { + trackSelector?.parameters?.preferredAudioLanguages?.firstOrNull() + } + ) { + playerOptionsInterface?.onAudioStreamClicked() + } + ) + items.add( + BottomSheetItem( + context.getString(R.string.captions), + R.drawable.ic_caption, + { if (trackSelector != null && trackSelector!!.parameters.preferredTextLanguages.isNotEmpty()) { trackSelector!!.parameters.preferredTextLanguages[0] } else { context.getString(R.string.none) } - ) - ) - } - - setItems(items) { index -> - when (index) { - 0 -> onAutoplayClicked() - 1 -> onRepeatModeClicked() - 2 -> onResizeModeClicked() - 3 -> onPlaybackSpeedClicked() - 4 -> playerOptionsInterface?.onQualityClicked() - 5 -> playerOptionsInterface?.onCaptionClicked() + } + ) { + playerOptionsInterface?.onCaptionsClicked() } - } + ) } - bottomSheetFragment.show(childFragmentManager, null) + + val bottomSheetFragment = BaseBottomSheet().setItems(items, null) + bottomSheetFragment.show(supportFragmentManager, null) } } @@ -222,14 +261,8 @@ internal class CustomExoPlayerView( binding.exoBottomBar.visibility = visibility binding.closeImageButton.visibility = visibility - // disable double tap to seek when the player is locked - if (isLocked) { - // enable fast forward and rewind by double tapping - enableDoubleTapToSeek() - } else { - // disable fast forward and rewind by double tapping - doubleTapListener = null - } + // disable double tap to seek if the player is locked + doubleTapAllowed = !isLocked } private fun enableDoubleTapToSeek() { @@ -237,15 +270,6 @@ internal class CustomExoPlayerView( val seekIncrementText = (PlayerHelper.seekIncrement / 1000).toString() doubleTapOverlayBinding?.rewindTV?.text = seekIncrementText doubleTapOverlayBinding?.forwardTV?.text = seekIncrementText - doubleTapListener = - object : DoubleTapInterface { - override fun onEvent(x: Float) { - when { - width * 0.5 > x -> rewind() - width * 0.5 < x -> forward() - } - } - } } private fun rewind() { @@ -307,9 +331,9 @@ internal class CustomExoPlayerView( } } - private fun onAutoplayClicked() { + override fun onAutoplayClicked() { // autoplay options dialog - BottomSheet() + BaseBottomSheet() .setSimpleItems( listOf( context.getString(R.string.enabled), @@ -321,14 +345,14 @@ internal class CustomExoPlayerView( 1 -> autoplayEnabled = false } } - .show(childFragmentManager) + .show(supportFragmentManager) } - private fun onPlaybackSpeedClicked() { - player?.let { PlaybackSpeedSheet(it).show(childFragmentManager) } + override fun onPlaybackSpeedClicked() { + player?.let { PlaybackSpeedSheet(it).show(supportFragmentManager) } } - private fun onResizeModeClicked() { + override fun onResizeModeClicked() { // switching between original aspect ratio (black bars) and zoomed to fill device screen val aspectRatioModeNames = context.resources?.getStringArray(R.array.resizeMode) ?.toList().orEmpty() @@ -339,30 +363,35 @@ internal class CustomExoPlayerView( AspectRatioFrameLayout.RESIZE_MODE_FILL ) - BottomSheet() + BaseBottomSheet() .setSimpleItems(aspectRatioModeNames) { index -> resizeMode = aspectRatioModes[index] } - .show(childFragmentManager) + .show(supportFragmentManager) } - private fun onRepeatModeClicked() { + override fun onRepeatModeClicked() { val repeatModeNames = listOf( context.getString(R.string.repeat_mode_none), - context.getString(R.string.repeat_mode_current) - ) - - val repeatModes = listOf( - RepeatModeUtil.REPEAT_TOGGLE_MODE_NONE, - RepeatModeUtil.REPEAT_TOGGLE_MODE_ALL - + context.getString(R.string.repeat_mode_current), + context.getString(R.string.all) ) // repeat mode options dialog - BottomSheet() + BaseBottomSheet() .setSimpleItems(repeatModeNames) { index -> - player?.repeatMode = repeatModes[index] + PlayingQueue.repeatQueue = when (index) { + 0 -> { + player?.repeatMode = Player.REPEAT_MODE_OFF + false + } + 1 -> { + player?.repeatMode = Player.REPEAT_MODE_ONE + false + } + else -> true + } } - .show(childFragmentManager) + .show(supportFragmentManager) } override fun onConfigurationChanged(newConfig: Configuration?) { diff --git a/app/src/main/java/com/github/libretube/ui/views/MarkableTimeBar.kt b/app/src/main/java/com/github/libretube/ui/views/MarkableTimeBar.kt new file mode 100644 index 000000000..90f53c1e8 --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/views/MarkableTimeBar.kt @@ -0,0 +1,79 @@ +package com.github.libretube.ui.views + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.util.AttributeSet +import com.github.libretube.R +import com.github.libretube.api.obj.Segment +import com.github.libretube.constants.PreferenceKeys +import com.github.libretube.util.PreferenceHelper +import com.github.libretube.util.ThemeHelper +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.ui.DefaultTimeBar + +/** + * TimeBar that can be marked with SponsorBlock Segments + */ +class MarkableTimeBar( + context: Context, + attributeSet: AttributeSet? = null +) : DefaultTimeBar(context, attributeSet) { + + private var segments: List = listOf() + private var player: Player? = null + private var length: Int = 0 + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + drawSegments(canvas) + } + + private fun drawSegments(canvas: Canvas) { + if (player == null) return + + if (!PreferenceHelper.getBoolean(PreferenceKeys.SB_SHOW_MARKERS, false)) return + + canvas.save() + length = canvas.width - 2 * HORIZONTAL_OFFSET + + val marginY = canvas.height / 2 - PROGRESS_BAR_HEIGHT / 2 + + segments.forEach { + canvas.drawRect( + Rect( + (it.segment.first() + HORIZONTAL_OFFSET).toLength(), + marginY - 1, + (it.segment.last() + HORIZONTAL_OFFSET).toLength(), + canvas.height - marginY + ), + Paint().apply { + color = ThemeHelper.getThemeColor(context, R.attr.colorOnSecondary) + } + ) + } + canvas.restore() + } + + private fun Double.toLength(): Int { + return (this * 1000 / player!!.duration * length).toInt() + } + + fun setSegments(segments: List) { + this.segments = segments + } + + fun clearSegments() { + segments = listOf() + } + + fun setPlayer(player: Player) { + this.player = player + } + + companion object { + const val HORIZONTAL_OFFSET = 10 + const val PROGRESS_BAR_HEIGHT = 4 + } +} diff --git a/app/src/main/java/com/github/libretube/ui/views/TimePickerPreference.kt b/app/src/main/java/com/github/libretube/ui/views/TimePickerPreference.kt new file mode 100644 index 000000000..3bb1830a2 --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/views/TimePickerPreference.kt @@ -0,0 +1,65 @@ +package com.github.libretube.ui.views + +import android.content.Context +import android.text.format.DateFormat.is24HourFormat +import android.util.AttributeSet +import androidx.appcompat.app.AppCompatActivity +import androidx.preference.Preference +import com.github.libretube.util.PreferenceHelper +import com.github.libretube.util.TextUtils +import com.google.android.material.timepicker.MaterialTimePicker +import com.google.android.material.timepicker.TimeFormat + +class TimePickerPreference( + context: Context, + attributeSet: AttributeSet +) : Preference(context, attributeSet) { + override fun getSummary(): CharSequence { + val prefStr = PreferenceHelper.getString(key, "") + return if (prefStr != "") prefStr else DEFAULT_VALUE + } + + override fun onClick() { + val picker = MaterialTimePicker.Builder() + .setInputMode(MaterialTimePicker.INPUT_MODE_CLOCK) + .setTimeFormat(getTimeFormat()) + .setHour(getHour()) + .setMinute(getMinutes()) + .build() + + picker.addOnPositiveButtonClickListener { + val timeStr = getTimeStr(picker) + PreferenceHelper.putString(key, timeStr) + summary = timeStr + } + picker.show((context as AppCompatActivity).supportFragmentManager, null) + } + + private fun getTimeFormat(): Int { + return if (is24HourFormat(context)) TimeFormat.CLOCK_24H else TimeFormat.CLOCK_12H + } + + private fun getPrefStringPart(index: Int): String? { + val prefStr = PreferenceHelper.getString(key, "").split(SEPARATOR).getOrNull(index) + return if (prefStr != "") prefStr else null + } + + private fun getHour(): Int { + return getPrefStringPart(0)?.toInt() ?: 0 + } + + private fun getMinutes(): Int { + return getPrefStringPart(1)?.toInt() ?: 0 + } + + private fun getTimeStr(picker: MaterialTimePicker): String { + val hour = TextUtils.toTwoDecimalsString(picker.hour) + val minute = TextUtils.toTwoDecimalsString(picker.minute) + return "$hour$SEPARATOR$minute" + } + + companion object { + const val SEPARATOR = ":" + const val DEFAULT_VALUE = "12:00" + } +} diff --git a/app/src/main/java/com/github/libretube/util/AutoPlayHelper.kt b/app/src/main/java/com/github/libretube/util/AutoPlayHelper.kt deleted file mode 100644 index 1e1f17e1f..000000000 --- a/app/src/main/java/com/github/libretube/util/AutoPlayHelper.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.github.libretube.util - -import com.github.libretube.api.RetrofitInstance -import com.github.libretube.extensions.toID -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -class AutoPlayHelper( - private val playlistId: String? -) { - - private val playlistStreamIds = mutableListOf() - private var playlistNextPage: String? = null - - /** - * get the id of the next video to be played - */ - suspend fun getNextVideoId( - currentVideoId: String, - relatedStreams: List? - ): String? { - return if (playlistId == null) { - getNextTrendingVideoId( - relatedStreams - ) - } else { - getNextPlaylistVideoId( - currentVideoId - ) - } - } - - /** - * get the id of the next related video - */ - private fun getNextTrendingVideoId( - relatedStreams: List? - ): String? { - // don't play a video if it got played before already - if (relatedStreams == null || relatedStreams.isEmpty()) return null - var index = 0 - var nextStreamId: String? = null - while (nextStreamId == null || PlayingQueue.containsBeforeCurrent(nextStreamId)) { - nextStreamId = relatedStreams[index].url!!.toID() - if (index + 1 < relatedStreams.size) { - index += 1 - } else { - break - } - } - return nextStreamId - } - - /** - * get the videoId of the next video in a playlist - */ - private suspend fun getNextPlaylistVideoId(currentVideoId: String): String? { - // if the playlists contain the video, then save the next video as next stream - if (playlistStreamIds.contains(currentVideoId)) { - val index = playlistStreamIds.indexOf(currentVideoId) - // check whether there's a next video - return if (index + 1 < playlistStreamIds.size) { - playlistStreamIds[index + 1] - } else if (playlistNextPage == null) { - null - } else { - getNextPlaylistVideoId(currentVideoId) - } - } else if (playlistStreamIds.isEmpty() || playlistNextPage != null) { - // fetch the next page of the playlist - return withContext(Dispatchers.IO) { - // fetch the playlists or its nextPage's videos - val playlist = - if (playlistNextPage == null) { - RetrofitInstance.authApi.getPlaylist(playlistId!!) - } else { - RetrofitInstance.authApi.getPlaylistNextPage( - playlistId!!, - playlistNextPage!! - ) - } - // save the playlist urls to the list - playlistStreamIds += playlist.relatedStreams!!.map { it.url!!.toID() } - // save playlistNextPage for usage if video is not contained - playlistNextPage = playlist.nextpage - return@withContext getNextPlaylistVideoId(currentVideoId) - } - } - // return null when no nextPage is found - return null - } -} diff --git a/app/src/main/java/com/github/libretube/util/BackupHelper.kt b/app/src/main/java/com/github/libretube/util/BackupHelper.kt index 3c18c59df..bf9a8c2c4 100644 --- a/app/src/main/java/com/github/libretube/util/BackupHelper.kt +++ b/app/src/main/java/com/github/libretube/util/BackupHelper.kt @@ -48,20 +48,32 @@ class BackupHelper(private val context: Context) { query { Database.watchHistoryDao().insertAll( - *backupFile.watchHistory?.toTypedArray().orEmpty() + *backupFile.watchHistory.orEmpty().toTypedArray() ) Database.searchHistoryDao().insertAll( - *backupFile.searchHistory?.toTypedArray().orEmpty() + *backupFile.searchHistory.orEmpty().toTypedArray() ) Database.watchPositionDao().insertAll( - *backupFile.watchPositions?.toTypedArray().orEmpty() + *backupFile.watchPositions.orEmpty().toTypedArray() ) Database.localSubscriptionDao().insertAll( - *backupFile.localSubscriptions?.toTypedArray().orEmpty() + *backupFile.localSubscriptions.orEmpty().toTypedArray() ) Database.customInstanceDao().insertAll( - *backupFile.customInstances?.toTypedArray().orEmpty() + *backupFile.customInstances.orEmpty().toTypedArray() ) + Database.playlistBookmarkDao().insertAll( + *backupFile.playlistBookmarks.orEmpty().toTypedArray() + ) + + backupFile.localPlaylists?.forEach { + Database.localPlaylistsDao().createPlaylist(it.playlist) + val playlistId = Database.localPlaylistsDao().getAll().last().playlist.id + it.videos.forEach { + it.playlistId = playlistId + Database.localPlaylistsDao().addPlaylistVideo(it) + } + } restorePreferences(backupFile.preferences) } diff --git a/app/src/main/java/com/github/libretube/util/DashHelper.kt b/app/src/main/java/com/github/libretube/util/DashHelper.kt new file mode 100644 index 000000000..52e7ea7a1 --- /dev/null +++ b/app/src/main/java/com/github/libretube/util/DashHelper.kt @@ -0,0 +1,179 @@ +package com.github.libretube.util + +import com.github.libretube.api.obj.PipedStream +import com.github.libretube.api.obj.Streams +import org.w3c.dom.Document +import org.w3c.dom.Element +import java.io.StringWriter +import javax.xml.parsers.DocumentBuilder +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult + +// Based off of https://github.com/TeamPiped/Piped/blob/master/src/utils/DashUtils.js + +object DashHelper { + + private val builderFactory: DocumentBuilderFactory = DocumentBuilderFactory.newInstance() + private val transformerFactory: TransformerFactory = TransformerFactory.newInstance() + + private data class AdapSetInfo( + val mimeType: String, + val audioTrackId: String? = null, + val formats: MutableList = mutableListOf() + ) + + fun createManifest(streams: Streams): String { + val builder: DocumentBuilder = builderFactory.newDocumentBuilder() + + val doc = builder.newDocument() + val mpd = doc.createElement("MPD") + mpd.setAttribute("xmlns", "urn:mpeg:dash:schema:mpd:2011") + mpd.setAttribute("profiles", "urn:mpeg:dash:profile:full:2011") + mpd.setAttribute("minBufferTime", "PT1.5S") + mpd.setAttribute("type", "static") + mpd.setAttribute("mediaPresentationDuration", "PT${streams.duration}S") + + val period = doc.createElement("Period") + + val adapSetInfos = ArrayList() + + for (stream in streams.videoStreams!!) { + // ignore dual format streams + if (!stream.videoOnly!!) { + continue + } + + // ignore streams which might be OTF + if (stream.indexEnd!! <= 0) { + continue + } + + val adapSetInfo = adapSetInfos.find { it.mimeType == stream.mimeType } + if (adapSetInfo != null) { + adapSetInfo.formats.add(stream) + continue + } + adapSetInfos.add( + AdapSetInfo( + stream.mimeType!!, + null, + mutableListOf(stream) + ) + ) + } + + for (stream in streams.audioStreams!!) { + val adapSetInfo = + adapSetInfos.find { it.mimeType == stream.mimeType && it.audioTrackId == stream.audioTrackId } + if (adapSetInfo != null) { + adapSetInfo.formats.add(stream) + continue + } + adapSetInfos.add( + AdapSetInfo( + stream.mimeType!!, + stream.audioTrackId, + mutableListOf(stream) + ) + ) + } + + for (adapSet in adapSetInfos) { + val adapSetElement = doc.createElement("AdaptationSet") + adapSetElement.setAttribute("mimeType", adapSet.mimeType) + adapSetElement.setAttribute("startWithSAP", "1") + adapSetElement.setAttribute("subsegmentAlignment", "true") + if (adapSet.audioTrackId != null) { + adapSetElement.setAttribute("lang", adapSet.audioTrackId.substring(0, 2)) + } + + val isVideo = adapSet.mimeType.contains("video") + + if (isVideo) { + adapSetElement.setAttribute("scanType", "progressive") + } + + for (stream in adapSet.formats) { + val rep = let { + if (isVideo) { + createVideoRepresentation(doc, stream) + } else { + createAudioRepresentation(doc, stream) + } + } + adapSetElement.appendChild(rep) + } + + period.appendChild(adapSetElement) + } + + mpd.appendChild(period) + + doc.appendChild(mpd) + + val domSource = DOMSource(doc) + val writer = StringWriter() + + val transformer = transformerFactory.newTransformer() + transformer.transform(domSource, StreamResult(writer)) + + return writer.toString() + } + + private fun createAudioRepresentation(doc: Document, stream: PipedStream): Element { + val representation = doc.createElement("Representation") + representation.setAttribute("bandwidth", stream.bitrate.toString()) + representation.setAttribute("codecs", stream.codec!!) + representation.setAttribute("mimeType", stream.mimeType!!) + + val audioChannelConfiguration = doc.createElement("AudioChannelConfiguration") + audioChannelConfiguration.setAttribute( + "schemeIdUri", + "urn:mpeg:dash:23003:3:audio_channel_configuration:2011" + ) + audioChannelConfiguration.setAttribute("value", "2") + + val baseUrl = doc.createElement("BaseURL") + baseUrl.appendChild(doc.createTextNode(stream.url!!)) + + val segmentBase = doc.createElement("SegmentBase") + segmentBase.setAttribute("indexRange", "${stream.indexStart}-${stream.indexEnd}") + + val initialization = doc.createElement("Initialization") + initialization.setAttribute("range", "${stream.initStart}-${stream.initEnd}") + segmentBase.appendChild(initialization) + + representation.appendChild(audioChannelConfiguration) + representation.appendChild(baseUrl) + representation.appendChild(segmentBase) + + return representation + } + + private fun createVideoRepresentation(doc: Document, stream: PipedStream): Element { + val representation = doc.createElement("Representation") + representation.setAttribute("codecs", stream.codec!!) + representation.setAttribute("bandwidth", stream.bitrate.toString()) + representation.setAttribute("width", stream.width.toString()) + representation.setAttribute("height", stream.height.toString()) + representation.setAttribute("maxPlayoutRate", "1") + representation.setAttribute("frameRate", stream.fps.toString()) + + val baseUrl = doc.createElement("BaseURL") + baseUrl.appendChild(doc.createTextNode(stream.url!!)) + + val segmentBase = doc.createElement("SegmentBase") + segmentBase.setAttribute("indexRange", "${stream.indexStart}-${stream.indexEnd}") + + val initialization = doc.createElement("Initialization") + initialization.setAttribute("range", "${stream.initStart}-${stream.initEnd}") + segmentBase.appendChild(initialization) + + representation.appendChild(baseUrl) + representation.appendChild(segmentBase) + + return representation + } +} diff --git a/app/src/main/java/com/github/libretube/util/DoubleTapListener.kt b/app/src/main/java/com/github/libretube/util/DoubleTapListener.kt deleted file mode 100644 index 5a96b4539..000000000 --- a/app/src/main/java/com/github/libretube/util/DoubleTapListener.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.github.libretube.util - -import android.os.Handler -import android.os.Looper -import android.os.SystemClock -import android.view.View - -abstract class DoubleTapListener : View.OnClickListener { - - private val maximumTimeDifference = 300L - private val handler = Handler(Looper.getMainLooper()) - - private var isSingleEvent = false - private var timeStampLastClick = 0L - private var timeStampLastDoubleClick = 0L - - override fun onClick(v: View?) { - if (SystemClock.elapsedRealtime() - timeStampLastClick < maximumTimeDifference) { - isSingleEvent = false - handler.removeCallbacks(runnable) - timeStampLastDoubleClick = SystemClock.elapsedRealtime() - onDoubleClick() - return - } - isSingleEvent = true - handler.removeCallbacks(runnable) - handler.postDelayed(runnable, maximumTimeDifference) - timeStampLastClick = SystemClock.elapsedRealtime() - } - - abstract fun onDoubleClick() - abstract fun onSingleClick() - - private val runnable = Runnable { - if (!isSingleEvent || - SystemClock.elapsedRealtime() - timeStampLastDoubleClick < maximumTimeDifference - ) { - return@Runnable - } - onSingleClick() - } -} diff --git a/app/src/main/java/com/github/libretube/util/DownloadHelper.kt b/app/src/main/java/com/github/libretube/util/DownloadHelper.kt index 30d52d0ca..041452811 100644 --- a/app/src/main/java/com/github/libretube/util/DownloadHelper.kt +++ b/app/src/main/java/com/github/libretube/util/DownloadHelper.kt @@ -2,13 +2,16 @@ package com.github.libretube.util import android.content.Context import android.os.Build -import com.github.libretube.constants.DownloadType -import com.github.libretube.extensions.createDir import com.github.libretube.obj.DownloadedFile import java.io.File object DownloadHelper { - private fun getOfflineStorageDir(context: Context): File { + const val VIDEO_DIR = "video" + const val AUDIO_DIR = "audio" + const val METADATA_DIR = "metadata" + const val THUMBNAIL_DIR = "thumbnail" + + fun getOfflineStorageDir(context: Context): File { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return context.filesDir return try { @@ -18,65 +21,35 @@ object DownloadHelper { } } - fun getVideoDir(context: Context): File { + fun getDownloadDir(context: Context, path: String): File { return File( getOfflineStorageDir(context), - "video" - ).createDir() + path + ).apply { + if (!this.exists()) this.mkdirs() + } } - fun getAudioDir(context: Context): File { - return File( - getOfflineStorageDir(context), - "audio" - ).createDir() - } - - fun getMetadataDir(context: Context): File { - return File( - getOfflineStorageDir(context), - "metadata" - ).createDir() - } - - fun getThumbnailDir(context: Context): File { - return File( - getOfflineStorageDir(context), - "thumbnail" - ).createDir() + private fun File.toDownloadedFile(): DownloadedFile { + return DownloadedFile( + name = this.name, + size = this.length() + ) } fun getDownloadedFiles(context: Context): MutableList { - val videoFiles = getVideoDir(context).listFiles() - val audioFiles = getAudioDir(context).listFiles()?.toMutableList() + val videoFiles = getDownloadDir(context, VIDEO_DIR).listFiles().orEmpty() + val audioFiles = getDownloadDir(context, AUDIO_DIR).listFiles().orEmpty().toMutableList() val files = mutableListOf() - videoFiles?.forEach { - var type = DownloadType.VIDEO - audioFiles?.forEach { audioFile -> - if (audioFile.name == it.name) { - type = DownloadType.AUDIO_VIDEO - audioFiles.remove(audioFile) - } - } - files.add( - DownloadedFile( - name = it.name, - size = it.length(), - type = type - ) - ) + videoFiles.forEach { + audioFiles.removeIf { audioFile -> audioFile.name == it.name } + files.add(it.toDownloadedFile()) } - audioFiles?.forEach { - files.add( - DownloadedFile( - name = it.name, - size = it.length(), - type = DownloadType.AUDIO - ) - ) + audioFiles.forEach { + files.add(it.toDownloadedFile()) } return files diff --git a/app/src/main/java/com/github/libretube/util/ImageHelper.kt b/app/src/main/java/com/github/libretube/util/ImageHelper.kt index ffad865bc..2e20f44dd 100644 --- a/app/src/main/java/com/github/libretube/util/ImageHelper.kt +++ b/app/src/main/java/com/github/libretube/util/ImageHelper.kt @@ -9,6 +9,7 @@ import android.widget.ImageView import coil.ImageLoader import coil.disk.DiskCache import coil.load +import coil.request.CachePolicy import coil.request.ImageRequest import com.github.libretube.api.CronetHelper import com.github.libretube.constants.PreferenceKeys @@ -25,17 +26,24 @@ object ImageHelper { fun initializeImageLoader(context: Context) { val maxImageCacheSize = PreferenceHelper.getString( PreferenceKeys.MAX_IMAGE_CACHE, - "128" - ).toInt() - - val diskCache = DiskCache.Builder() - .directory(context.filesDir.resolve("coil")) - .maxSizeBytes(maxImageCacheSize * 1024 * 1024L) - .build() + "" + ) imageLoader = ImageLoader.Builder(context) .callFactory(CronetHelper.callFactory) - .diskCache(diskCache) + .apply { + when (maxImageCacheSize) { + "" -> { + diskCachePolicy(CachePolicy.DISABLED) + } + else -> diskCache( + DiskCache.Builder() + .directory(context.filesDir.resolve("coil")) + .maxSizeBytes(maxImageCacheSize.toInt() * 1024 * 1024L) + .build() + ) + } + } .build() } @@ -56,16 +64,11 @@ object ImageHelper { .data(url) .target { result -> val bitmap = (result as BitmapDrawable).bitmap - saveImage( - context, - bitmap, - Uri.fromFile( - File( - DownloadHelper.getThumbnailDir(context), - fileName - ) - ) + val file = File( + DownloadHelper.getDownloadDir(context, DownloadHelper.THUMBNAIL_DIR), + fileName ) + saveImage(context, bitmap, Uri.fromFile(file)) } .build() @@ -73,15 +76,12 @@ object ImageHelper { } fun getDownloadedImage(context: Context, fileName: String): Bitmap? { - return getImage( - context, - Uri.fromFile( - File( - DownloadHelper.getThumbnailDir(context), - fileName - ) - ) + val file = File( + DownloadHelper.getDownloadDir(context, DownloadHelper.THUMBNAIL_DIR), + fileName ) + if (!file.exists()) return null + return getImage(context, Uri.fromFile(file)) } private fun saveImage(context: Context, bitmapImage: Bitmap, imagePath: Uri) { diff --git a/app/src/main/java/com/github/libretube/util/ImportHelper.kt b/app/src/main/java/com/github/libretube/util/ImportHelper.kt index b02c05e65..525bb95dd 100644 --- a/app/src/main/java/com/github/libretube/util/ImportHelper.kt +++ b/app/src/main/java/com/github/libretube/util/ImportHelper.kt @@ -9,6 +9,7 @@ import com.github.libretube.R import com.github.libretube.api.RetrofitInstance import com.github.libretube.api.SubscriptionHelper import com.github.libretube.extensions.TAG +import com.github.libretube.extensions.toastFromMainThread import com.github.libretube.obj.NewPipeSubscription import com.github.libretube.obj.NewPipeSubscriptions import kotlinx.coroutines.CoroutineScope @@ -17,56 +18,69 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import java.io.FileOutputStream -class ImportHelper(private val activity: Activity) { +class ImportHelper( + private val activity: Activity +) { /** * Import subscriptions by a file uri */ fun importSubscriptions(uri: Uri?) { if (uri == null) return try { - val channels = when (activity.contentResolver.getType(uri)) { - "application/json" -> { - // NewPipe subscriptions format - val mapper = ObjectMapper() - val json = activity.contentResolver.openInputStream(uri)?.use { - it.bufferedReader().use { reader -> reader.readText() } - }.orEmpty() - - val subscriptions = mapper.readValue(json, NewPipeSubscriptions::class.java) - subscriptions.subscriptions.orEmpty().map { - it.url!!.replace("https://www.youtube.com/channel/", "") - } - } - "text/csv", "text/comma-separated-values" -> { - // import subscriptions from Google/YouTube Takeout - activity.contentResolver.openInputStream(uri)?.use { - it.bufferedReader().useLines { lines -> - lines.map { line -> line.substringBefore(",") } - .filter { channelId -> channelId.length == 24 } - .toList() - } - }.orEmpty() - } - else -> throw IllegalArgumentException("Unsupported file type") - } - + val applicationContext = activity.applicationContext + val channels = getChannelsFromUri(uri) CoroutineScope(Dispatchers.IO).launch { SubscriptionHelper.importSubscriptions(channels) + }.invokeOnCompletion { + applicationContext.toastFromMainThread(R.string.importsuccess) } - - Toast.makeText(activity, R.string.importsuccess, Toast.LENGTH_SHORT).show() - } catch (e: Exception) { + } catch (e: IllegalArgumentException) { Log.e(TAG(), e.toString()) Toast.makeText( activity, - R.string.error, + activity.getString(R.string.unsupported_file_format) + + " (${activity.contentResolver.getType(uri)}", Toast.LENGTH_SHORT ).show() + } catch (e: Exception) { + Log.e(TAG(), e.toString()) + Toast.makeText(activity, e.localizedMessage, Toast.LENGTH_SHORT).show() } } /** - * write the text to the document + * Get a list of channel IDs from a file [Uri] + */ + private fun getChannelsFromUri(uri: Uri): List { + return when (val fileType = activity.contentResolver.getType(uri)) { + "application/json", "application/octet-stream" -> { + // NewPipe subscriptions format + val mapper = ObjectMapper() + val json = activity.contentResolver.openInputStream(uri)?.use { + it.bufferedReader().use { reader -> reader.readText() } + }.orEmpty() + + val subscriptions = mapper.readValue(json, NewPipeSubscriptions::class.java) + subscriptions.subscriptions.orEmpty().map { + it.url!!.replace("https://www.youtube.com/channel/", "") + } + } + "text/csv", "text/comma-separated-values" -> { + // import subscriptions from Google/YouTube Takeout + activity.contentResolver.openInputStream(uri)?.use { + it.bufferedReader().useLines { lines -> + lines.map { line -> line.substringBefore(",") } + .filter { channelId -> channelId.length == 24 } + .toList() + } + }.orEmpty() + } + else -> throw IllegalArgumentException("Unsupported file type: $fileType") + } + } + + /** + * Write the text to the document */ fun exportSubscriptions(uri: Uri?) { if (uri == null) return diff --git a/app/src/main/java/com/github/libretube/util/LocaleHelper.kt b/app/src/main/java/com/github/libretube/util/LocaleHelper.kt index 5783b6d69..017a96022 100644 --- a/app/src/main/java/com/github/libretube/util/LocaleHelper.kt +++ b/app/src/main/java/com/github/libretube/util/LocaleHelper.kt @@ -1,6 +1,8 @@ package com.github.libretube.util import android.content.Context +import android.content.res.Configuration +import android.content.res.Resources import android.os.Build import android.telephony.TelephonyManager import com.github.libretube.constants.PreferenceKeys @@ -11,44 +13,48 @@ object LocaleHelper { fun updateLanguage(context: Context) { val languageName = PreferenceHelper.getString(PreferenceKeys.LANGUAGE, "sys") - if (languageName == "sys") { - updateLocaleConf(context, Locale.getDefault()) - } else if (languageName.contains("-") == true) { - val languageParts = languageName.split("-") - val locale = Locale( - languageParts[0], - languageParts[1] - ) - updateLocaleConf(context, locale) - } else { - val locale = Locale(languageName.toString()) - updateLocaleConf(context, locale) + val locale = when { + languageName == "sys" -> Locale.getDefault() + languageName.contains("-") == true -> { + val languageParts = languageName.split("-") + Locale( + languageParts[0], + languageParts[1].replace("r", "") + ) + } + else -> Locale(languageName) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) updateResources(context, locale) + updateResourcesLegacy(context, locale) } - private fun updateLocaleConf(context: Context, locale: Locale) { - // Change API Language + private fun updateResources(context: Context, locale: Locale) { Locale.setDefault(locale) + val configuration: Configuration = context.resources.configuration + configuration.setLocale(locale) + context.createConfigurationContext(configuration) + } - // Change App Language - val res = context.resources - val dm = res.displayMetrics - val conf = res.configuration - conf.setLocale(locale) - res.updateConfiguration(conf, dm) + @Suppress("DEPRECATION") + private fun updateResourcesLegacy(context: Context, locale: Locale) { + Locale.setDefault(locale) + val resources: Resources = context.resources + val configuration: Configuration = resources.getConfiguration() + configuration.locale = locale + resources.updateConfiguration(configuration, resources.getDisplayMetrics()) } fun getDetectedCountry(context: Context, defaultCountryIsoCode: String): String { detectSIMCountry(context)?.let { - return it + if (it != "") return it } detectNetworkCountry(context)?.let { - return it + if (it != "") return it } detectLocaleCountry(context)?.let { - return it + if (it != "") return it } return defaultCountryIsoCode @@ -120,4 +126,16 @@ object LocaleHelper { } return locales } + + fun getTrendingRegion(context: Context): String { + val regionPref = PreferenceHelper.getString(PreferenceKeys.REGION, "sys") + + // get the system default country if auto region selected + return if (regionPref == "sys") { + getDetectedCountry(context, "UK") + .uppercase() + } else { + regionPref + } + } } diff --git a/app/src/main/java/com/github/libretube/util/MetadataHelper.kt b/app/src/main/java/com/github/libretube/util/MetadataHelper.kt index 0cb6b7172..a2b46d13d 100644 --- a/app/src/main/java/com/github/libretube/util/MetadataHelper.kt +++ b/app/src/main/java/com/github/libretube/util/MetadataHelper.kt @@ -11,7 +11,7 @@ class MetadataHelper( private val context: Context ) { private val mapper = ObjectMapper() - private val metadataDir = DownloadHelper.getMetadataDir(context) + private val metadataDir = DownloadHelper.getDownloadDir(context, DownloadHelper.METADATA_DIR) fun createMetadata(fileName: String, streams: Streams) { val targetFile = File(metadataDir, fileName) diff --git a/app/src/main/java/com/github/libretube/util/NavBarHelper.kt b/app/src/main/java/com/github/libretube/util/NavBarHelper.kt index 6190982fa..b1dc00330 100644 --- a/app/src/main/java/com/github/libretube/util/NavBarHelper.kt +++ b/app/src/main/java/com/github/libretube/util/NavBarHelper.kt @@ -1,55 +1,70 @@ package com.github.libretube.util import android.content.Context +import android.util.Log import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.widget.PopupMenu import androidx.core.view.forEach -import com.fasterxml.jackson.core.type.TypeReference -import com.fasterxml.jackson.databind.ObjectMapper +import androidx.core.view.get +import androidx.core.view.size import com.github.libretube.R import com.github.libretube.constants.PreferenceKeys -import com.github.libretube.obj.NavBarItem import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.navigation.NavigationBarView object NavBarHelper { - private const val preferenceKey = "nav_bar_items" + private const val SEPARATOR = "," - private val mapper = ObjectMapper() - - fun getNavBarItems(context: Context): List { - return try { - val type = object : TypeReference>() {} - mapper.readValue( - PreferenceHelper.getString( - preferenceKey, - "" - ), - type - ) + // contains "-" -> invisible menu item, else -> visible menu item + fun getNavBarItems(context: Context): List { + val prefItems = try { + PreferenceHelper.getString( + PreferenceKeys.NAVBAR_ITEMS, + "" + ).split(SEPARATOR) } catch (e: Exception) { - val p = PopupMenu(context, null) - MenuInflater(context).inflate(R.menu.bottom_menu, p.menu) - val defaultNavBarItems = mutableListOf() - p.menu.forEach { - defaultNavBarItems.add( - NavBarItem( - it.itemId, - it.title.toString(), - it.isEnabled - ) + Log.e("fail to parse nav items", e.toString()) + return getDefaultNavBarItems(context) + } + val p = PopupMenu(context, null) + MenuInflater(context).inflate(R.menu.bottom_menu, p.menu) + + if (prefItems.size == p.menu.size) { + val navBarItems = mutableListOf() + prefItems.forEach { + navBarItems.add( + p.menu[it.replace("-", "").toInt()].apply { + this.isVisible = !it.contains("-") + } ) } - return defaultNavBarItems + return navBarItems } + return getDefaultNavBarItems(context) } - fun setNavBarItems(items: List) { + private fun getDefaultNavBarItems(context: Context): List { + val p = PopupMenu(context, null) + MenuInflater(context).inflate(R.menu.bottom_menu, p.menu) + val navBarItems = mutableListOf() + p.menu.forEach { + navBarItems.add(it) + } + return navBarItems + } + + fun setNavBarItems(items: List, context: Context) { + val prefString = mutableListOf() + val defaultNavBarItems = getDefaultNavBarItems(context) + items.forEach { newItem -> + val index = defaultNavBarItems.indexOfFirst { newItem.itemId == it.itemId } + prefString.add(if (newItem.isVisible) index.toString() else "-$index") + } PreferenceHelper.putString( - preferenceKey, - mapper.writeValueAsString(items) + PreferenceKeys.NAVBAR_ITEMS, + prefString.joinToString(SEPARATOR) ) } @@ -74,14 +89,14 @@ object NavBarHelper { // remove the old items navBarItems.forEach { menuItems.add( - bottomNav.menu.findItem(it.id) + bottomNav.menu.findItem(it.itemId) ) - bottomNav.menu.removeItem(it.id) + bottomNav.menu.removeItem(it.itemId) } navBarItems.forEach { navBarItem -> - if (navBarItem.isEnabled) { - val menuItem = menuItems.filter { it.itemId == navBarItem.id }[0] + if (navBarItem.isVisible) { + val menuItem = menuItems.first { it.itemId == navBarItem.itemId } bottomNav.menu.add( menuItem.groupId, @@ -91,6 +106,6 @@ object NavBarHelper { ).icon = menuItem.icon } } - return navBarItems.filter { it.isEnabled }[0].id + return navBarItems.first { it.isVisible }.itemId } } diff --git a/app/src/main/java/com/github/libretube/util/NavigationHelper.kt b/app/src/main/java/com/github/libretube/util/NavigationHelper.kt index 58b1bce9e..0131b8b90 100644 --- a/app/src/main/java/com/github/libretube/util/NavigationHelper.kt +++ b/app/src/main/java/com/github/libretube/util/NavigationHelper.kt @@ -9,6 +9,7 @@ import androidx.appcompat.app.AppCompatActivity import androidx.core.os.bundleOf import com.github.libretube.R import com.github.libretube.constants.IntentData +import com.github.libretube.enums.PlaylistType import com.github.libretube.extensions.toID import com.github.libretube.ui.activities.MainActivity import com.github.libretube.ui.fragments.PlayerFragment @@ -59,14 +60,14 @@ object NavigationHelper { fun navigatePlaylist( context: Context, playlistId: String?, - isOwner: Boolean + playlistType: PlaylistType ) { if (playlistId == null) return val activity = context as MainActivity val bundle = Bundle() bundle.putString(IntentData.playlistId, playlistId) - bundle.putBoolean("isOwner", isOwner) + bundle.putSerializable(IntentData.playlistType, playlistType) activity.navController.navigate(R.id.playlistFragment, bundle) } diff --git a/app/src/main/java/com/github/libretube/util/NotificationHelper.kt b/app/src/main/java/com/github/libretube/util/NotificationHelper.kt index 7780cfbb0..046401f99 100644 --- a/app/src/main/java/com/github/libretube/util/NotificationHelper.kt +++ b/app/src/main/java/com/github/libretube/util/NotificationHelper.kt @@ -1,46 +1,22 @@ package com.github.libretube.util -import android.app.NotificationManager -import android.app.PendingIntent import android.content.Context -import android.content.Intent -import android.os.Build -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.NetworkType import androidx.work.PeriodicWorkRequest import androidx.work.WorkManager -import com.github.libretube.R -import com.github.libretube.api.RetrofitInstance -import com.github.libretube.api.SubscriptionHelper import com.github.libretube.constants.NOTIFICATION_WORK_NAME -import com.github.libretube.constants.PUSH_CHANNEL_ID import com.github.libretube.constants.PreferenceKeys -import com.github.libretube.extensions.toID -import com.github.libretube.ui.activities.MainActivity -import kotlinx.coroutines.async -import kotlinx.coroutines.runBlocking +import com.github.libretube.workers.NotificationWorker import java.util.concurrent.TimeUnit -class NotificationHelper( - private val context: Context -) { - val NotificationManager = - context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - // the id where notification channels start - private var notificationId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - NotificationManager.activeNotifications.size + 5 - } else { - 5 - } - +object NotificationHelper { /** * Enqueue the work manager task */ fun enqueueWork( + context: Context, existingPeriodicWorkPolicy: ExistingPeriodicWorkPolicy ) { // get the notification preferences @@ -95,115 +71,4 @@ class NotificationHelper( .cancelUniqueWork(NOTIFICATION_WORK_NAME) } } - - /** - * check whether new streams are available in subscriptions - */ - fun checkForNewStreams(): Boolean { - var success = true - - val token = PreferenceHelper.getToken() - runBlocking { - val task = async { - if (token != "") { - RetrofitInstance.authApi.getFeed(token) - } else { - RetrofitInstance.authApi.getUnauthenticatedFeed( - SubscriptionHelper.getFormattedLocalSubscriptions() - ) - } - } - // fetch the users feed - val videoFeed = try { - task.await() - } catch (e: Exception) { - success = false - return@runBlocking - } - - val lastSeenStreamId = PreferenceHelper.getLastSeenVideoId() - val latestFeedStreamId = videoFeed[0].url!!.toID() - - // first time notifications enabled or no new video available - if (lastSeenStreamId == "" || lastSeenStreamId == latestFeedStreamId) { - PreferenceHelper.setLatestVideoId(lastSeenStreamId) - return@runBlocking - } - - // filter the new videos out - val lastSeenStreamItem = videoFeed.filter { it.url!!.toID() == lastSeenStreamId } - - // previous video not found - if (lastSeenStreamItem.isEmpty()) return@runBlocking - - val lastStreamIndex = videoFeed.indexOf(lastSeenStreamItem[0]) - val newVideos = videoFeed.filterIndexed { index, _ -> - index < lastStreamIndex - } - - // group the new streams by the uploader - val channelGroups = newVideos.groupBy { it.uploaderUrl } - // create a notification for each new stream - channelGroups.forEach { (_, streams) -> - createNotification( - group = streams[0].uploaderUrl!!.toID(), - title = streams[0].uploaderName.toString(), - isSummary = true - ) - - streams.forEach { streamItem -> - notificationId += 1 - createNotification( - title = streamItem.title.toString(), - description = streamItem.uploaderName.toString(), - group = streamItem.uploaderUrl!!.toID() - ) - } - } - // save the latest streams that got notified about - PreferenceHelper.setLatestVideoId(videoFeed[0].url!!.toID()) - } - // return whether the work succeeded - return success - } - - /** - * Notification that is created when new streams are found - */ - private fun createNotification( - title: String, - group: String, - description: String? = null, - isSummary: Boolean = false - ) { - val intent = Intent(context, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - } - val pendingIntent: PendingIntent = PendingIntent.getActivity( - context, - 0, - intent, - PendingIntent.FLAG_IMMUTABLE - ) - - val builder = NotificationCompat.Builder(context, PUSH_CHANNEL_ID) - .setContentTitle(title) - .setGroup(group) - .setSmallIcon(R.drawable.ic_notification) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - // Set the intent that will fire when the user taps the notification - .setContentIntent(pendingIntent) - .setAutoCancel(true) - - if (isSummary) { - builder.setGroupSummary(true) - } else { - builder.setContentText(description) - } - - with(NotificationManagerCompat.from(context)) { - // notificationId is a unique int for each notification that you must define - notify(notificationId, builder.build()) - } - } } diff --git a/app/src/main/java/com/github/libretube/util/NotificationWorker.kt b/app/src/main/java/com/github/libretube/util/NotificationWorker.kt deleted file mode 100644 index ea7e06da2..000000000 --- a/app/src/main/java/com/github/libretube/util/NotificationWorker.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.github.libretube.util - -import android.content.Context -import androidx.work.Worker -import androidx.work.WorkerParameters - -/** - * The notification worker which checks for new streams in a certain frequency - */ -class NotificationWorker(appContext: Context, parameters: WorkerParameters) : - Worker(appContext, parameters) { - - override fun doWork(): Result { - // check whether there are new streams and notify if there are some - val result = NotificationHelper(applicationContext) - .checkForNewStreams() - // return success if the API request succeeded - return if (result) Result.success() else Result.retry() - } -} diff --git a/app/src/main/java/com/github/libretube/util/NowPlayingNotification.kt b/app/src/main/java/com/github/libretube/util/NowPlayingNotification.kt index aec065c26..425bab2af 100644 --- a/app/src/main/java/com/github/libretube/util/NowPlayingNotification.kt +++ b/app/src/main/java/com/github/libretube/util/NowPlayingNotification.kt @@ -5,19 +5,27 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.content.res.Resources import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.graphics.drawable.BitmapDrawable import android.os.Build +import android.os.Bundle +import android.support.v4.media.MediaDescriptionCompat +import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaSessionCompat import coil.request.ImageRequest +import com.github.libretube.R import com.github.libretube.api.obj.Streams import com.github.libretube.constants.BACKGROUND_CHANNEL_ID import com.github.libretube.constants.IntentData import com.github.libretube.constants.PLAYER_NOTIFICATION_ID +import com.github.libretube.constants.PreferenceKeys import com.github.libretube.ui.activities.MainActivity import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector +import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator import com.google.android.exoplayer2.ui.PlayerNotificationManager class NowPlayingNotification( @@ -67,7 +75,11 @@ class NowPlayingNotification( // that's the only way to launch back into the previous activity (e.g. the player view val intent = Intent(context, MainActivity::class.java).apply { if (isBackgroundPlayerNotification) { - putExtra(IntentData.videoId, videoId) + if (PreferenceHelper.getBoolean(PreferenceKeys.NOTIFICATION_OPEN_QUEUE, true)) { + putExtra(IntentData.openQueueOnce, true) + } else { + putExtra(IntentData.videoId, videoId) + } addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) } } @@ -98,28 +110,33 @@ class NowPlayingNotification( callback: PlayerNotificationManager.BitmapCallback ): Bitmap? { var bitmap: Bitmap? = null - var resizedBitmap: Bitmap? = null val request = ImageRequest.Builder(context) .data(streams?.thumbnailUrl) .target { result -> bitmap = (result as BitmapDrawable).bitmap - resizedBitmap = Bitmap.createScaledBitmap( - bitmap!!, - bitmap!!.width, - bitmap!!.width, - false - ) } .build() ImageHelper.imageLoader.enqueue(request) - // returns the scaled bitmap if it got fetched successfully - return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) resizedBitmap else bitmap + // returns the bitmap on Android 13+, for everything below scaled down to a square + return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) getSquareBitmap(bitmap) else bitmap } } + private fun getSquareBitmap(bitmap: Bitmap?): Bitmap? { + bitmap ?: return null + val newSize = minOf(bitmap.width, bitmap.height) + return Bitmap.createBitmap( + bitmap, + (bitmap.width - newSize) / 2, + (bitmap.height - newSize) / 2, + newSize, + newSize + ) + } + /** * Creates a [MediaSessionCompat] amd a [MediaSessionConnector] for the player */ @@ -129,6 +146,27 @@ class NowPlayingNotification( mediaSession.isActive = true mediaSessionConnector = MediaSessionConnector(mediaSession) + mediaSessionConnector.setQueueNavigator(object : TimelineQueueNavigator(mediaSession) { + override fun getMediaDescription( + player: Player, + windowIndex: Int + ): MediaDescriptionCompat { + return MediaDescriptionCompat.Builder().apply { + setTitle(streams?.title!!) + setSubtitle(streams?.uploader) + val extras = Bundle() + val appIcon = BitmapFactory.decodeResource( + Resources.getSystem(), + R.drawable.ic_launcher_monochrome + ) + extras.putParcelable(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, appIcon) + extras.putString(MediaMetadataCompat.METADATA_KEY_TITLE, streams?.title!!) + extras.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, streams?.uploader) + setIconBitmap(appIcon) + setExtras(extras) + }.build() + } + }) mediaSessionConnector.setPlayer(player) } @@ -175,17 +213,17 @@ class NowPlayingNotification( * Destroy the [NowPlayingNotification] */ fun destroySelfAndPlayer() { + playerNotification?.setPlayer(null) + mediaSession.isActive = false mediaSession.release() - mediaSessionConnector.setPlayer(null) - playerNotification?.setPlayer(null) + + player.stop() + player.release() val notificationManager = context.getSystemService( Context.NOTIFICATION_SERVICE ) as NotificationManager notificationManager.cancel(PLAYER_NOTIFICATION_ID) - - player.stop() - player.release() } } diff --git a/app/src/main/java/com/github/libretube/util/PlayerHelper.kt b/app/src/main/java/com/github/libretube/util/PlayerHelper.kt index 1821de4ae..346bb854d 100644 --- a/app/src/main/java/com/github/libretube/util/PlayerHelper.kt +++ b/app/src/main/java/com/github/libretube/util/PlayerHelper.kt @@ -3,6 +3,7 @@ package com.github.libretube.util import android.content.Context import android.content.pm.ActivityInfo import android.view.accessibility.CaptioningManager +import com.github.libretube.api.obj.PipedStream import com.github.libretube.constants.PreferenceKeys import com.google.android.exoplayer2.ui.CaptionStyleCompat import com.google.android.exoplayer2.video.VideoSize @@ -12,7 +13,7 @@ object PlayerHelper { // get the audio source following the users preferences fun getAudioSource( context: Context, - audios: List + audios: List ): String { val audioFormat = PreferenceHelper.getString(PreferenceKeys.PLAYER_AUDIO_FORMAT, "all") val audioQuality = if ( @@ -39,7 +40,7 @@ object PlayerHelper { } // get the best bit rate from audio streams - private fun getMostBitRate(audios: List): String { + private fun getMostBitRate(audios: List): String { var bitrate = 0 var audioUrl = "" audios.forEach { @@ -52,7 +53,7 @@ object PlayerHelper { } // get the best bit rate from audio streams - private fun getLeastBitRate(audios: List): String { + private fun getLeastBitRate(audios: List): String { var bitrate = 1000000000 var audioUrl = "" audios.forEach { @@ -255,12 +256,6 @@ object PlayerHelper { false ) - val progressiveLoadingIntervalSize: String - get() = PreferenceHelper.getString( - PreferenceKeys.PROGRESSIVE_LOADING_INTERVAL_SIZE, - "64" - ) - val autoPlayEnabled: Boolean get() = PreferenceHelper.getBoolean( PreferenceKeys.AUTO_PLAY, @@ -287,6 +282,18 @@ object PlayerHelper { "fit" ) + val alternativeVideoLayout: Boolean + get() = PreferenceHelper.getBoolean( + PreferenceKeys.ALTERNATIVE_PLAYER_LAYOUT, + false + ) + + val autoInsertRelatedVideos: Boolean + get() = PreferenceHelper.getBoolean( + PreferenceKeys.QUEUE_AUTO_INSERT_RELATED, + true + ) + fun getDefaultResolution(context: Context): String { return if (NetworkHelper.isNetworkMobile(context)) { PreferenceHelper.getString( diff --git a/app/src/main/java/com/github/libretube/util/PlayingQueue.kt b/app/src/main/java/com/github/libretube/util/PlayingQueue.kt index 7063c1d7d..7f2cfdaf1 100644 --- a/app/src/main/java/com/github/libretube/util/PlayingQueue.kt +++ b/app/src/main/java/com/github/libretube/util/PlayingQueue.kt @@ -1,55 +1,145 @@ package com.github.libretube.util -object PlayingQueue { - private val queue = mutableListOf() - private var currentVideoId: String? = null +import android.util.Log +import com.github.libretube.api.PlaylistsHelper +import com.github.libretube.api.RetrofitInstance +import com.github.libretube.api.obj.StreamItem +import com.github.libretube.extensions.move +import com.github.libretube.extensions.toID +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch - fun add(videoId: String) { - if (currentVideoId == videoId) return - if (queue.contains(videoId)) queue.remove(videoId) - queue.add(videoId) +object PlayingQueue { + private val queue = mutableListOf() + private var currentStream: StreamItem? = null + private var onQueueTapListener: (StreamItem) -> Unit = {} + var repeatQueue: Boolean = false + + fun add(vararg streamItem: StreamItem) { + streamItem.forEach { + if (currentStream != it) { + if (queue.contains(it)) queue.remove(it) + queue.add(it) + } + } } - fun addAsNext(videoId: String) { - if (currentVideoId == videoId) return - if (queue.contains(videoId)) queue.remove(videoId) + fun addAsNext(streamItem: StreamItem) { + if (currentStream == streamItem) return + if (queue.contains(streamItem)) queue.remove(streamItem) queue.add( - queue.indexOf(currentVideoId) + 1, - videoId + currentIndex() + 1, + streamItem ) } fun getNext(): String? { - return try { - queue[currentIndex() + 1] + try { + return queue[currentIndex() + 1].url?.toID() } catch (e: Exception) { - null + Log.e("queue ended", e.toString()) } + if (repeatQueue) return queue.firstOrNull()?.url?.toID() + return null } fun getPrev(): String? { - val index = queue.indexOf(currentVideoId) - return if (index > 0) queue[index - 1] else null + val index = queue.indexOf(currentStream) + return if (index > 0) queue[index - 1].url?.toID() else null } fun hasPrev(): Boolean { - return queue.indexOf(currentVideoId) > 0 + return queue.indexOf(currentStream) > 0 } - fun updateCurrent(videoId: String) { - currentVideoId = videoId - queue.add(videoId) + fun updateCurrent(streamItem: StreamItem) { + currentStream = streamItem + if (!contains(streamItem)) queue.add(streamItem) } fun isNotEmpty() = queue.isNotEmpty() - fun clear() = queue.clear() + fun isEmpty() = queue.isEmpty() - fun currentIndex() = queue.indexOf(currentVideoId) + fun size() = queue.size - fun contains(videoId: String) = queue.contains(videoId) + fun currentIndex(): Int { + return try { + queue.indexOf( + queue.first { it.url?.toID() == currentStream?.url?.toID() } + ) + } catch (e: Exception) { + 0 + } + } - fun containsBeforeCurrent(videoId: String): Boolean { - return queue.contains(videoId) && queue.indexOf(videoId) < currentIndex() + fun contains(streamItem: StreamItem) = queue.any { it.url?.toID() == streamItem.url?.toID() } + + fun getStreams() = queue + + fun setStreams(streams: List) { + queue.clear() + queue.addAll(streams) + } + + fun remove(index: Int) = queue.removeAt(index) + + fun move(from: Int, to: Int) = queue.move(from, to) + + private fun fetchMoreFromPlaylist(playlistId: String, nextPage: String?) { + var playlistNextPage: String? = nextPage + CoroutineScope(Dispatchers.IO).launch { + while (playlistNextPage != null) { + RetrofitInstance.authApi.getPlaylistNextPage( + playlistId, + playlistNextPage!! + ).apply { + add( + *this.relatedStreams.orEmpty().toTypedArray() + ) + playlistNextPage = this.nextpage + } + } + } + } + + fun insertPlaylist(playlistId: String, newCurrentStream: StreamItem) { + CoroutineScope(Dispatchers.IO).launch { + try { + val playlistType = PlaylistsHelper.getPrivateType(playlistId) + val playlist = PlaylistsHelper.getPlaylist(playlistType, playlistId) + add( + *playlist.relatedStreams + .orEmpty() + .toTypedArray() + ) + updateCurrent(newCurrentStream) + if (playlist.nextpage == null) return@launch + fetchMoreFromPlaylist(playlistId, playlist.nextpage) + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + fun onQueueItemSelected(index: Int) { + try { + val streamItem = queue[index] + updateCurrent(streamItem) + onQueueTapListener.invoke(streamItem) + } catch (e: Exception) { + Log.e("Queue on tap", "lifecycle already ended") + } + } + + fun setOnQueueTapListener(listener: (StreamItem) -> Unit) { + onQueueTapListener = listener + } + + fun resetToDefaults() { + repeatQueue = false + onQueueTapListener = {} + queue.clear() } } diff --git a/app/src/main/java/com/github/libretube/util/PreferenceHelper.kt b/app/src/main/java/com/github/libretube/util/PreferenceHelper.kt index 0861bc555..e9ac81e0d 100644 --- a/app/src/main/java/com/github/libretube/util/PreferenceHelper.kt +++ b/app/src/main/java/com/github/libretube/util/PreferenceHelper.kt @@ -29,12 +29,16 @@ object PreferenceHelper { authEditor = authSettings.edit() } - fun putString(key: String?, value: String) { + fun putString(key: String, value: String) { editor.putString(key, value).commit() } - fun getString(key: String?, defValue: String?): String { - return settings.getString(key, defValue)!! + fun putBoolean(key: String, value: Boolean) { + editor.putBoolean(key, value).commit() + } + + fun getString(key: String?, defValue: String): String { + return settings.getString(key, defValue) ?: defValue } fun getBoolean(key: String?, defValue: Boolean): Boolean { @@ -49,10 +53,6 @@ object PreferenceHelper { editor.clear().apply() } - fun removePreference(value: String?) { - editor.remove(value).apply() - } - fun getToken(): String { return authSettings.getString(PreferenceKeys.TOKEN, "")!! } @@ -85,6 +85,23 @@ object PreferenceHelper { return getString(PreferenceKeys.ERROR_LOG, "") } + fun getIgnorableNotificationChannels(): List { + return getString(PreferenceKeys.IGNORED_NOTIFICATION_CHANNELS, "").split(",") + } + + fun isChannelNotificationIgnorable(channelId: String): Boolean { + return getIgnorableNotificationChannels().any { it == channelId } + } + + fun toggleIgnorableNotificationChannel(channelId: String) { + val ignorableChannels = getIgnorableNotificationChannels().toMutableList() + if (ignorableChannels.contains(channelId)) ignorableChannels.remove(channelId) else ignorableChannels.add(channelId) + editor.putString( + PreferenceKeys.IGNORED_NOTIFICATION_CHANNELS, + ignorableChannels.joinToString(",") + ).apply() + } + private fun getDefaultSharedPreferences(context: Context): SharedPreferences { return PreferenceManager.getDefaultSharedPreferences(context) } diff --git a/app/src/main/java/com/github/libretube/util/ProxyHelper.kt b/app/src/main/java/com/github/libretube/util/ProxyHelper.kt new file mode 100644 index 000000000..4889e0f11 --- /dev/null +++ b/app/src/main/java/com/github/libretube/util/ProxyHelper.kt @@ -0,0 +1,52 @@ +package com.github.libretube.util + +import com.github.libretube.api.RetrofitInstance +import com.github.libretube.constants.PreferenceKeys +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.net.URI +import java.net.URLDecoder +import java.nio.charset.StandardCharsets +import java.util.* + +object ProxyHelper { + private fun getImageProxyUrl(): String? { + val url = PreferenceHelper.getString(PreferenceKeys.IMAGE_PROXY_URL, "") + return if (url != "") url else null + } + + private fun setImageProxyUrl(url: String) { + PreferenceHelper.putString(PreferenceKeys.IMAGE_PROXY_URL, url) + } + + fun fetchProxyUrl() { + CoroutineScope(Dispatchers.IO).launch { + runCatching { + RetrofitInstance.api.getConfig().imageProxyUrl?.let { + setImageProxyUrl(it) + } + } + } + } + + fun rewriteUrl(url: String?): String? { + url ?: return null + + val proxyUrl = getImageProxyUrl() + proxyUrl ?: return url + + runCatching { + val originalUri = URI(url) + val newUri = URI( + originalUri.scheme.lowercase(Locale.US), + URI(proxyUrl).authority, + originalUri.path, + originalUri.query, + originalUri.fragment + ) + return URLDecoder.decode(newUri.toString(), StandardCharsets.UTF_8.toString()) + } + return url + } +} diff --git a/app/src/main/java/com/github/libretube/util/TextUtils.kt b/app/src/main/java/com/github/libretube/util/TextUtils.kt new file mode 100644 index 000000000..8b8cc4008 --- /dev/null +++ b/app/src/main/java/com/github/libretube/util/TextUtils.kt @@ -0,0 +1,12 @@ +package com.github.libretube.util + +object TextUtils { + /** + * Separator used for descriptions + */ + const val SEPARATOR = " • " + + fun toTwoDecimalsString(num: Int): String { + return if (num >= 10) num.toString() else "0$num" + } +} diff --git a/app/src/main/java/com/github/libretube/util/ThemeHelper.kt b/app/src/main/java/com/github/libretube/util/ThemeHelper.kt index 524ffabc6..35b26befd 100644 --- a/app/src/main/java/com/github/libretube/util/ThemeHelper.kt +++ b/app/src/main/java/com/github/libretube/util/ThemeHelper.kt @@ -11,6 +11,7 @@ import androidx.appcompat.app.AppCompatDelegate import androidx.core.text.HtmlCompat import com.github.libretube.R import com.github.libretube.constants.PreferenceKeys +import com.github.libretube.ui.adapters.IconsSheetAdapter import com.google.android.material.color.DynamicColors object ThemeHelper { @@ -93,15 +94,9 @@ object ThemeHelper { * change the app icon */ fun changeIcon(context: Context, newLogoActivityAlias: String) { - val activityAliases = context.resources.getStringArray(R.array.iconsValue) // Disable Old Icon(s) - for (activityAlias in activityAliases) { - val activityClass = "com.github.libretube." + - if (activityAlias == activityAliases[0]) { - "ui.activities.MainActivity" // default icon/activity - } else { - activityAlias - } + for (appIcon in IconsSheetAdapter.availableIcons) { + val activityClass = "com.github.libretube." + appIcon.activityAlias // remove old icons context.packageManager.setComponentEnabledSetting( @@ -112,12 +107,7 @@ object ThemeHelper { } // set the class name for the activity alias - val newLogoActivityClass = "com.github.libretube." + - if (newLogoActivityAlias == activityAliases[0]) { - "ui.activities.MainActivity" // default icon/activity - } else { - newLogoActivityAlias - } + val newLogoActivityClass = "com.github.libretube." + newLogoActivityAlias // Enable New Icon context.packageManager.setComponentEnabledSetting( ComponentName(context.packageName, newLogoActivityClass), diff --git a/app/src/main/java/com/github/libretube/workers/NotificationWorker.kt b/app/src/main/java/com/github/libretube/workers/NotificationWorker.kt new file mode 100644 index 000000000..7d5e9b801 --- /dev/null +++ b/app/src/main/java/com/github/libretube/workers/NotificationWorker.kt @@ -0,0 +1,189 @@ +package com.github.libretube.workers + +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.github.libretube.R +import com.github.libretube.api.SubscriptionHelper +import com.github.libretube.constants.PUSH_CHANNEL_ID +import com.github.libretube.constants.PreferenceKeys +import com.github.libretube.extensions.toID +import com.github.libretube.ui.activities.MainActivity +import com.github.libretube.ui.views.TimePickerPreference +import com.github.libretube.util.PreferenceHelper +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import java.time.LocalTime + +/** + * The notification worker which checks for new streams in a certain frequency + */ +class NotificationWorker(appContext: Context, parameters: WorkerParameters) : + Worker(appContext, parameters) { + + private val notificationManager = + appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // the id where notification channels start + private var notificationId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + notificationManager.activeNotifications.size + 5 + } else { + 5 + } + + override fun doWork(): Result { + if (!checkTime()) Result.success() + // check whether there are new streams and notify if there are some + val result = checkForNewStreams() + // return success if the API request succeeded + return if (result) Result.success() else Result.retry() + } + + /** + * Determine whether the time is valid to notify + */ + private fun checkTime(): Boolean { + if (!PreferenceHelper.getBoolean( + PreferenceKeys.NOTIFICATION_TIME_ENABLED, + false + ) + ) { + return true + } + + val start = getTimePickerPref(PreferenceKeys.NOTIFICATION_START_TIME) + val end = getTimePickerPref(PreferenceKeys.NOTIFICATION_END_TIME) + + val currentTime = LocalTime.now() + val isOverNight = start > end + + val startValid = if (isOverNight) start > currentTime else start < currentTime + val endValid = if (isOverNight) end < currentTime else start > currentTime + + return (startValid && endValid) + } + + private fun getTimePickerPref(key: String): LocalTime { + return LocalTime.parse( + PreferenceHelper.getString(key, TimePickerPreference.DEFAULT_VALUE) + ) + } + + /** + * check whether new streams are available in subscriptions + */ + private fun checkForNewStreams(): Boolean { + var success = true + + runBlocking { + val task = async { + SubscriptionHelper.getFeed() + } + // fetch the users feed + val videoFeed = try { + task.await() + } catch (e: Exception) { + success = false + return@runBlocking + } + + val lastSeenStreamId = PreferenceHelper.getLastSeenVideoId() + val latestFeedStreamId = videoFeed[0].url!!.toID() + + // first time notifications enabled or no new video available + if (lastSeenStreamId == "" || lastSeenStreamId == latestFeedStreamId) { + PreferenceHelper.setLatestVideoId(lastSeenStreamId) + return@runBlocking + } + + // filter the new videos out + val lastSeenStreamItem = videoFeed.filter { it.url!!.toID() == lastSeenStreamId } + + // previous video not found + if (lastSeenStreamItem.isEmpty()) return@runBlocking + + val lastStreamIndex = videoFeed.indexOf(lastSeenStreamItem[0]) + val newVideos = videoFeed.filterIndexed { index, _ -> + index < lastStreamIndex + } + + // hide for notifications unsubscribed channels + val channelsToIgnore = PreferenceHelper.getIgnorableNotificationChannels() + val filteredVideos = newVideos.filter { + channelsToIgnore.none { channelId -> + channelId == it.uploaderUrl?.toID() + } + } + + // group the new streams by the uploader + val channelGroups = filteredVideos.groupBy { it.uploaderUrl } + // create a notification for each new stream + channelGroups.forEach { (_, streams) -> + createNotification( + group = streams[0].uploaderUrl!!.toID(), + title = streams[0].uploaderName.toString(), + isSummary = true + ) + + streams.forEach { streamItem -> + notificationId += 1 + createNotification( + title = streamItem.title.toString(), + description = streamItem.uploaderName.toString(), + group = streamItem.uploaderUrl!!.toID() + ) + } + } + // save the latest streams that got notified about + PreferenceHelper.setLatestVideoId(videoFeed[0].url!!.toID()) + } + // return whether the work succeeded + return success + } + + /** + * Notification that is created when new streams are found + */ + private fun createNotification( + title: String, + group: String, + description: String? = null, + isSummary: Boolean = false + ) { + val intent = Intent(applicationContext, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + val pendingIntent: PendingIntent = PendingIntent.getActivity( + applicationContext, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE + ) + + val builder = NotificationCompat.Builder(applicationContext, PUSH_CHANNEL_ID) + .setContentTitle(title) + .setGroup(group) + .setSmallIcon(R.drawable.ic_launcher_lockscreen) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + // Set the intent that will fire when the user taps the notification + .setContentIntent(pendingIntent) + .setAutoCancel(true) + + if (isSummary) { + builder.setGroupSummary(true) + } else { + builder.setContentText(description) + } + + with(NotificationManagerCompat.from(applicationContext)) { + // notificationId is a unique int for each notification that you must define + notify(notificationId, builder.build()) + } + } +} diff --git a/app/src/main/res/drawable/ic_audio.xml b/app/src/main/res/drawable/ic_audio.xml new file mode 100644 index 000000000..ba1442961 --- /dev/null +++ b/app/src/main/res/drawable/ic_audio.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_bell.xml b/app/src/main/res/drawable/ic_bell.xml new file mode 100644 index 000000000..a803bb008 --- /dev/null +++ b/app/src/main/res/drawable/ic_bell.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_bookmark.xml b/app/src/main/res/drawable/ic_bookmark.xml new file mode 100644 index 000000000..d32812f77 --- /dev/null +++ b/app/src/main/res/drawable/ic_bookmark.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_bookmark_outlined.xml b/app/src/main/res/drawable/ic_bookmark_outlined.xml new file mode 100644 index 000000000..00c636e83 --- /dev/null +++ b/app/src/main/res/drawable/ic_bookmark_outlined.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_check.xml b/app/src/main/res/drawable/ic_check.xml new file mode 100644 index 000000000..b648f5705 --- /dev/null +++ b/app/src/main/res/drawable/ic_check.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_forward.xml b/app/src/main/res/drawable/ic_forward.xml index 1f165a420..580ecf823 100644 --- a/app/src/main/res/drawable/ic_forward.xml +++ b/app/src/main/res/drawable/ic_forward.xml @@ -1,6 +1,6 @@ diff --git a/app/src/main/res/drawable/ic_launcher_light_foreground.xml b/app/src/main/res/drawable/ic_launcher_light_foreground.xml new file mode 100644 index 000000000..6078c9eb5 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_light_foreground.xml @@ -0,0 +1,20 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_lockscreen.xml b/app/src/main/res/drawable/ic_launcher_lockscreen.xml new file mode 100644 index 000000000..1c2eb3863 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_lockscreen.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_locked.xml b/app/src/main/res/drawable/ic_locked.xml index a1014afc6..5aa4c2024 100644 --- a/app/src/main/res/drawable/ic_locked.xml +++ b/app/src/main/res/drawable/ic_locked.xml @@ -1,7 +1,7 @@ + + diff --git a/app/src/main/res/drawable/ic_restart.xml b/app/src/main/res/drawable/ic_restart.xml new file mode 100644 index 000000000..576fccffe --- /dev/null +++ b/app/src/main/res/drawable/ic_restart.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/ic_reverse.xml b/app/src/main/res/drawable/ic_reverse.xml new file mode 100644 index 000000000..1daf24465 --- /dev/null +++ b/app/src/main/res/drawable/ic_reverse.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_rewind.xml b/app/src/main/res/drawable/ic_rewind.xml index 843bba82b..abb2576c5 100644 --- a/app/src/main/res/drawable/ic_rewind.xml +++ b/app/src/main/res/drawable/ic_rewind.xml @@ -1,6 +1,6 @@ diff --git a/app/src/main/res/drawable/ic_share_outlined.xml b/app/src/main/res/drawable/ic_share_outlined.xml new file mode 100644 index 000000000..8a0cf890a --- /dev/null +++ b/app/src/main/res/drawable/ic_share_outlined.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/ic_shuffle.xml b/app/src/main/res/drawable/ic_shuffle.xml new file mode 100644 index 000000000..67d78b7a1 --- /dev/null +++ b/app/src/main/res/drawable/ic_shuffle.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/activity_nointernet.xml b/app/src/main/res/layout/activity_nointernet.xml index 90513e469..cfd3708f4 100644 --- a/app/src/main/res/layout/activity_nointernet.xml +++ b/app/src/main/res/layout/activity_nointernet.xml @@ -25,12 +25,14 @@ android:id="@+id/noInternet_imageView" android:layout_width="200dp" android:layout_height="200dp" + android:layout_gravity="center" app:srcCompat="@drawable/ic_no_wifi" /> diff --git a/app/src/main/res/layout/app_icon_item.xml b/app/src/main/res/layout/app_icon_item.xml new file mode 100644 index 000000000..14eb9abcd --- /dev/null +++ b/app/src/main/res/layout/app_icon_item.xml @@ -0,0 +1,28 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/channel_subscription_row.xml b/app/src/main/res/layout/channel_subscription_row.xml index 2fefef887..b82b9a477 100644 --- a/app/src/main/res/layout/channel_subscription_row.xml +++ b/app/src/main/res/layout/channel_subscription_row.xml @@ -1,5 +1,5 @@ - + + - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/chapter_column.xml b/app/src/main/res/layout/chapter_column.xml index 243941ae7..220f7990e 100644 --- a/app/src/main/res/layout/chapter_column.xml +++ b/app/src/main/res/layout/chapter_column.xml @@ -15,12 +15,40 @@ android:orientation="vertical" android:paddingHorizontal="5dp"> - + android:layout_height="55dp"> + + + + + + + + + + @@ -18,12 +19,28 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="8dp" - android:padding="8dp" /> + android:paddingVertical="8dp" + android:paddingStart="8dp" + android:paddingEnd="40dp" + tools:ignore="RtlSymmetry" /> -