Merge branch 'libre-tube:master' into master

This commit is contained in:
XelXen 2022-11-24 09:57:40 +05:30 committed by GitHub
commit e59c251437
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
259 changed files with 8608 additions and 3234 deletions

67
.github/tg.py vendored
View File

@ -1,33 +1,34 @@
import telegram import asyncio
from tgconfig import * from json import load
from json import load from os import listdir
import multiprocessing
from os import system from pyrogram import Client
from time import sleep as wait from pyrogram.types import InputMediaDocument
from tgconfig import *
def deploy():
system(f'./exec --local --api-id={TG_API_ID} --api-hash={TG_API_HASH}') files = listdir()
def bot(): mediadocuments = [
wait(10) InputMediaDocument(file) for file in files if file.endswith("signed.apk")
f = open('commit.json') ]
data = load(f)
f.close() with open("commit.json") as f:
data = load(f)
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* caption = f"""**Libretube {data['sha'][0:7]} // Alpha**
[{data['commit']['message']}]({data['html_url']}) <a href="{data['html_url']}">{data['commit']['message']}</a>
Signed-off-by: {data['commit']['author']['name']} 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')
async def main():
if __name__ == '__main__': async with Client("libretube", TG_API_ID, TG_API_HASH, bot_token=TG_TOKEN) as app:
multideploy = multiprocessing.Process(target=deploy) await app.send_photo(
multibot = multiprocessing.Process(target=bot) int(TG_POST_ID), "https://libre-tube.github.io/images/Alpha.png", caption
multideploy.start() )
multibot.start() await app.send_media_group(int(TG_POST_ID), mediadocuments)
multideploy.join()
multibot.join()
asyncio.run(main())

View File

@ -21,7 +21,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: gradle/wrapper-validation-action@v1 - uses: gradle/wrapper-validation-action@v1
- uses: actions/setup-python@v3 - uses: actions/setup-python@v4
with: with:
python-version: '3.x' # Version range or exact version of a Python version to use, using SemVer's version range syntax 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 architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
@ -54,6 +54,18 @@ jobs:
cd .. cd ..
./gradlew assembleDebug ./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 - name: Upload to Archive
continue-on-error: true continue-on-error: true
run: | run: |
@ -61,7 +73,7 @@ jobs:
echo "GH_REPO = '${{ github.repository }}'" > tgconfig.py echo "GH_REPO = '${{ github.repository }}'" > tgconfig.py
git clone https://github.com/LibreTubeAlpha/Archive archive git clone https://github.com/LibreTubeAlpha/Archive archive
rm -rf archive/*.apk rm -rf archive/*.apk
mv app/build/outputs/apk/debug/*.apk archive/ mv app/build/outputs/apk/debug/*-signed.apk archive/
cd archive cd archive
python ../uploader.py python ../uploader.py
@ -69,15 +81,12 @@ jobs:
continue-on-error: true continue-on-error: true
run: | run: |
cd archive cd archive
curl https://libre-tube.github.io/images/Alpha.png --output alpha.png
chmod 755 ./exec
mv ../tgconfig.py . mv ../tgconfig.py .
echo "TG_TOKEN = '${{ secrets.TG_TOKEN }}'" >> tgconfig.py echo "TG_TOKEN = '${{ secrets.TG_TOKEN }}'" >> tgconfig.py
echo "TG_API_ID = '${{ secrets.TG_API_ID }}'" >> tgconfig.py echo "TG_API_ID = '${{ secrets.TG_API_ID }}'" >> tgconfig.py
echo "TG_POST_ID = '${{ secrets.TG_POST_ID }}'" >> tgconfig.py echo "TG_POST_ID = '${{ secrets.TG_POST_ID }}'" >> tgconfig.py
echo "TG_API_HASH = '${{ secrets.TG_API_HASH }}'" >> tgconfig.py echo "TG_API_HASH = '${{ secrets.TG_API_HASH }}'" >> tgconfig.py
python -m pip install --upgrade pip python -m pip install --upgrade pip TgCrypto Pyrogram
pip install python-telegram-bot
mv ../.github/tg.py . mv ../.github/tg.py .
mv ../.github/commit.json . mv ../.github/commit.json .
python tg.py python tg.py
@ -86,4 +95,5 @@ jobs:
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: app name: app
path: archive/*.apk path: archive/*-signed.apk

View File

@ -85,7 +85,7 @@ If creating a pull request, please make sure to format your code (preferred ktli
<img src="https://hosted.weblate.org/widgets/libretube/-/287x66-grey.png" alt="Translation status" /> <img src="https://hosted.weblate.org/widgets/libretube/-/287x66-grey.png" alt="Translation status" />
</a> </a>
## 🌗 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. 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.

View File

@ -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. Feel free to help us if you have any knowledge concerning the following planned features or anything else you imagine.
## Planned ## Planned
- Support for local playlists - Currently only various smaller features
- Various smaller features
## Not planned ## Not planned
- Google/MicroG Login - Google/MicroG Login

View File

@ -13,11 +13,17 @@ android {
applicationId 'com.github.libretube' applicationId 'com.github.libretube'
minSdk 21 minSdk 21
targetSdk 33 targetSdk 33
versionCode 20 versionCode 23
versionName '0.6.1' versionName '0.8.0'
multiDexEnabled true multiDexEnabled true
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
resValue "string", "app_name", "LibreTube" resValue "string", "app_name", "LibreTube"
javaCompileOptions {
annotationProcessorOptions {
arguments += ["room.schemaLocation": "$projectDir/schemas".toString()]
}
}
} }
buildFeatures { buildFeatures {
@ -41,6 +47,8 @@ android {
} }
debug { debug {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
debuggable true debuggable true
applicationIdSuffix ".debug" applicationIdSuffix ".debug"
resValue "string", "app_name", "LibreTube Debug" resValue "string", "app_name", "LibreTube Debug"
@ -101,6 +109,7 @@ dependencies {
implementation libs.exoplayer implementation libs.exoplayer
implementation(libs.exoplayer.extension.cronet) { exclude group: 'com.google.android.gms' } implementation(libs.exoplayer.extension.cronet) { exclude group: 'com.google.android.gms' }
implementation libs.exoplayer.extension.mediasession implementation libs.exoplayer.extension.mediasession
implementation libs.exoplayer.dash
/* Retrofit and Jackson */ /* Retrofit and Jackson */
implementation libs.square.retrofit implementation libs.square.retrofit

View File

@ -16,15 +16,16 @@
# debugging stack traces. # debugging stack traces.
-keepattributes SourceFile,LineNumberTable -keepattributes SourceFile,LineNumberTable
# prevents obfuscation in debug logs
-dontobfuscate
# If you keep the line number information, uncomment this to # If you keep the line number information, uncomment this to
# hide the original source file name. # hide the original source file name.
#-renamesourcefileattribute SourceFile #-renamesourcefileattribute SourceFile
#uncomment for debug #uncomment for debug
#-keepnames class ** #-keepnames class **
# Keep data classes used for Retrofit
-keep class com.github.libretube.obj.** { *; } -keep class com.github.libretube.obj.** { *; }
-keep class com.github.libretube.obj.update.** { *; }
# prevents android from removing it
-keep class com.github.libretube.obj.**.** { *; }
# prevents obfuscation in debug logs
-dontobfuscate

View File

@ -11,36 +11,10 @@
"type": "UNIVERSAL", "type": "UNIVERSAL",
"filters": [], "filters": [],
"attributes": [], "attributes": [],
"versionCode": 20, "versionCode": 23,
"versionName": "0.6.1", "versionName": "0.8.0",
"outputFile": "app-universal-release.apk" "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", "type": "ONE_OF_MANY",
"filters": [ "filters": [
@ -50,10 +24,23 @@
} }
], ],
"attributes": [], "attributes": [],
"versionCode": 20, "versionCode": 23,
"versionName": "0.6.1", "versionName": "0.8.0",
"outputFile": "app-x86-release.apk" "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", "type": "ONE_OF_MANY",
"filters": [ "filters": [
@ -63,9 +50,22 @@
} }
], ],
"attributes": [], "attributes": [],
"versionCode": 20, "versionCode": 23,
"versionName": "0.6.1", "versionName": "0.8.0",
"outputFile": "app-x86_64-release.apk" "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" "elementType": "File"

View File

@ -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')"
]
}
}

View File

@ -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')"
]
}
}

View File

@ -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')"
]
}
}

View File

@ -3,6 +3,13 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:installLocation="auto"> android:installLocation="auto">
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@ -20,7 +27,8 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/StartupTheme" android:theme="@style/StartupTheme"
tools:targetApi="n"> tools:targetApi="n"
android:banner="@mipmap/ic_launcher">
<activity <activity
android:name=".ui.activities.NoInternetActivity" android:name=".ui.activities.NoInternetActivity"
@ -59,6 +67,9 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" /> <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter> </intent-filter>
<meta-data android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity> </activity>
<activity-alias <activity-alias
@ -79,6 +90,32 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" /> <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter> </intent-filter>
<meta-data android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias>
<activity-alias
android:name=".DefaultLight"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
android:enabled="false"
android:exported="true"
android:hardwareAccelerated="true"
android:icon="@mipmap/ic_launcher_light"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_light_round"
android:supportsPictureInPicture="true"
android:targetActivity=".ui.activities.MainActivity"
android:windowSoftInputMode="adjustPan">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<meta-data android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias> </activity-alias>
<activity-alias <activity-alias
@ -99,6 +136,9 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" /> <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter> </intent-filter>
<meta-data android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias> </activity-alias>
<activity-alias <activity-alias
@ -119,6 +159,9 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" /> <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter> </intent-filter>
<meta-data android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias> </activity-alias>
<activity-alias <activity-alias
@ -137,6 +180,9 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<meta-data android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias> </activity-alias>
<activity-alias <activity-alias
@ -157,6 +203,9 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" /> <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter> </intent-filter>
<meta-data android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias> </activity-alias>
<activity-alias <activity-alias
@ -177,6 +226,9 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" /> <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter> </intent-filter>
<meta-data android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias> </activity-alias>
<activity-alias <activity-alias
@ -197,6 +249,9 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" /> <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter> </intent-filter>
<meta-data android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias> </activity-alias>
<activity <activity
@ -298,6 +353,7 @@
<data android:scheme="http" /> <data android:scheme="http" />
<data android:scheme="https" /> <data android:scheme="https" />
<data android:host="piped.video" />
<data android:host="piped.tokhmi.xyz" /> <data android:host="piped.tokhmi.xyz" />
<data android:host="piped.kavin.rocks" /> <data android:host="piped.kavin.rocks" />
<data android:host="piped.silkky.cloud" /> <data android:host="piped.silkky.cloud" />
@ -329,4 +385,4 @@
android:exported="false" /> android:exported="false" />
</application> </application>
</manifest> </manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -16,6 +16,7 @@ import com.github.libretube.util.ExceptionHandler
import com.github.libretube.util.ImageHelper import com.github.libretube.util.ImageHelper
import com.github.libretube.util.NotificationHelper import com.github.libretube.util.NotificationHelper
import com.github.libretube.util.PreferenceHelper import com.github.libretube.util.PreferenceHelper
import com.github.libretube.util.ProxyHelper
class LibreTubeApp : Application() { class LibreTubeApp : Application() {
override fun onCreate() { override fun onCreate() {
@ -52,10 +53,16 @@ class LibreTubeApp : Application() {
/** /**
* Initialize the notification listener in the background * Initialize the notification listener in the background
*/ */
NotificationHelper(this).enqueueWork( NotificationHelper.enqueueWork(
context = this,
existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.KEEP existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.KEEP
) )
/**
* Fetch the image proxy URL for local playlists and the watch history
*/
ProxyHelper.fetchProxyUrl()
/** /**
* Handler for uncaught exceptions * Handler for uncaught exceptions
*/ */

View File

@ -1,5 +1,22 @@
package com.github.libretube.api 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.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Header import retrofit2.http.Header
@ -8,81 +25,90 @@ import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
interface PipedApi { interface PipedApi {
@GET("config")
suspend fun getConfig(): PipedConfig
@GET("trending") @GET("trending")
suspend fun getTrending(@Query("region") region: String): List<com.github.libretube.api.obj.StreamItem> suspend fun getTrending(@Query("region") region: String): List<StreamItem>
@GET("streams/{videoId}") @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}") @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}") @GET("sponsors/{videoId}")
suspend fun getSegments( suspend fun getSegments(
@Path("videoId") videoId: String, @Path("videoId") videoId: String,
@Query("category") category: String @Query("category") category: String
): com.github.libretube.api.obj.Segments ): SegmentData
@GET("nextpage/comments/{videoId}") @GET("nextpage/comments/{videoId}")
suspend fun getCommentsNextPage( suspend fun getCommentsNextPage(
@Path("videoId") videoId: String, @Path("videoId") videoId: String,
@Query("nextpage") nextPage: String @Query("nextpage") nextPage: String
): com.github.libretube.api.obj.CommentsPage ): CommentsPage
@GET("search") @GET("search")
suspend fun getSearchResults( suspend fun getSearchResults(
@Query("q") searchQuery: String, @Query("q") searchQuery: String,
@Query("filter") filter: String @Query("filter") filter: String
): com.github.libretube.api.obj.SearchResult ): SearchResult
@GET("nextpage/search") @GET("nextpage/search")
suspend fun getSearchResultsNextPage( suspend fun getSearchResultsNextPage(
@Query("q") searchQuery: String, @Query("q") searchQuery: String,
@Query("filter") filter: String, @Query("filter") filter: String,
@Query("nextpage") nextPage: String @Query("nextpage") nextPage: String
): com.github.libretube.api.obj.SearchResult ): SearchResult
@GET("suggestions") @GET("suggestions")
suspend fun getSuggestions(@Query("query") query: String): List<String> suspend fun getSuggestions(@Query("query") query: String): List<String>
@GET("channel/{channelId}") @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}") @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}") @GET("nextpage/channel/{channelId}")
suspend fun getChannelNextPage( suspend fun getChannelNextPage(
@Path("channelId") channelId: String, @Path("channelId") channelId: String,
@Query("nextpage") nextPage: String @Query("nextpage") nextPage: String
): com.github.libretube.api.obj.Channel ): Channel
@GET("playlists/{playlistId}") @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}") @GET("nextpage/playlists/{playlistId}")
suspend fun getPlaylistNextPage( suspend fun getPlaylistNextPage(
@Path("playlistId") playlistId: String, @Path("playlistId") playlistId: String,
@Query("nextpage") nextPage: String @Query("nextpage") nextPage: String
): com.github.libretube.api.obj.Playlist ): Playlist
@POST("login") @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") @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") @POST("user/delete")
suspend fun deleteAccount( suspend fun deleteAccount(
@Header("Authorization") token: String, @Header("Authorization") token: String,
@Body password: com.github.libretube.api.obj.DeleteUserRequest @Body password: DeleteUserRequest
) )
@GET("feed") @GET("feed")
suspend fun getFeed(@Query("authToken") token: String?): List<com.github.libretube.api.obj.StreamItem> suspend fun getFeed(@Query("authToken") token: String?): List<StreamItem>
@GET("feed/unauthenticated") @GET("feed/unauthenticated")
suspend fun getUnauthenticatedFeed(@Query("channels") channels: String): List<com.github.libretube.api.obj.StreamItem> suspend fun getUnauthenticatedFeed(@Query("channels") channels: String): List<StreamItem>
@GET("subscribed") @GET("subscribed")
suspend fun isSubscribed( suspend fun isSubscribed(
@ -91,66 +117,66 @@ interface PipedApi {
): com.github.libretube.api.obj.Subscribed ): com.github.libretube.api.obj.Subscribed
@GET("subscriptions") @GET("subscriptions")
suspend fun subscriptions(@Header("Authorization") token: String): List<com.github.libretube.api.obj.Subscription> suspend fun subscriptions(@Header("Authorization") token: String): List<Subscription>
@GET("subscriptions/unauthenticated") @GET("subscriptions/unauthenticated")
suspend fun unauthenticatedSubscriptions(@Query("channels") channels: String): List<com.github.libretube.api.obj.Subscription> suspend fun unauthenticatedSubscriptions(@Query("channels") channels: String): List<Subscription>
@POST("subscribe") @POST("subscribe")
suspend fun subscribe( suspend fun subscribe(
@Header("Authorization") token: String, @Header("Authorization") token: String,
@Body subscribe: com.github.libretube.api.obj.Subscribe @Body subscribe: Subscribe
): com.github.libretube.api.obj.Message ): Message
@POST("unsubscribe") @POST("unsubscribe")
suspend fun unsubscribe( suspend fun unsubscribe(
@Header("Authorization") token: String, @Header("Authorization") token: String,
@Body subscribe: com.github.libretube.api.obj.Subscribe @Body subscribe: Subscribe
): com.github.libretube.api.obj.Message ): Message
@POST("import") @POST("import")
suspend fun importSubscriptions( suspend fun importSubscriptions(
@Query("override") override: Boolean, @Query("override") override: Boolean,
@Header("Authorization") token: String, @Header("Authorization") token: String,
@Body channels: List<String> @Body channels: List<String>
): com.github.libretube.api.obj.Message ): Message
@POST("import/playlist") @POST("import/playlist")
suspend fun importPlaylist( suspend fun importPlaylist(
@Header("Authorization") token: String, @Header("Authorization") token: String,
@Body playlistId: com.github.libretube.api.obj.PlaylistId @Body playlistId: PlaylistId
): com.github.libretube.api.obj.Message ): PlaylistId
@GET("user/playlists") @GET("user/playlists")
suspend fun playlists(@Header("Authorization") token: String): List<com.github.libretube.api.obj.Playlists> suspend fun getUserPlaylists(@Header("Authorization") token: String): List<Playlists>
@POST("user/playlists/rename") @POST("user/playlists/rename")
suspend fun renamePlaylist( suspend fun renamePlaylist(
@Header("Authorization") token: String, @Header("Authorization") token: String,
@Body playlistId: com.github.libretube.api.obj.PlaylistId @Body playlistId: PlaylistId
) )
@POST("user/playlists/delete") @POST("user/playlists/delete")
suspend fun deletePlaylist( suspend fun deletePlaylist(
@Header("Authorization") token: String, @Header("Authorization") token: String,
@Body playlistId: com.github.libretube.api.obj.PlaylistId @Body playlistId: PlaylistId
): com.github.libretube.api.obj.Message ): Message
@POST("user/playlists/create") @POST("user/playlists/create")
suspend fun createPlaylist( suspend fun createPlaylist(
@Header("Authorization") token: String, @Header("Authorization") token: String,
@Body name: com.github.libretube.api.obj.Playlists @Body name: Playlists
): com.github.libretube.api.obj.PlaylistId ): PlaylistId
@POST("user/playlists/add") @POST("user/playlists/add")
suspend fun addToPlaylist( suspend fun addToPlaylist(
@Header("Authorization") token: String, @Header("Authorization") token: String,
@Body playlistId: com.github.libretube.api.obj.PlaylistId @Body playlistId: PlaylistId
): com.github.libretube.api.obj.Message ): Message
@POST("user/playlists/remove") @POST("user/playlists/remove")
suspend fun removeFromPlaylist( suspend fun removeFromPlaylist(
@Header("Authorization") token: String, @Header("Authorization") token: String,
@Body playlistId: com.github.libretube.api.obj.PlaylistId @Body playlistId: PlaylistId
): com.github.libretube.api.obj.Message ): Message
} }

View File

@ -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<Playlists> {
if (loggedIn()) return RetrofitInstance.authApi.getUserPlaylists(token)
val localPlaylists = awaitQuery {
DatabaseHolder.Database.localPlaylistsDao().getAll()
}
val playlists = mutableListOf<Playlists>()
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
}
}

View File

@ -1,12 +1,18 @@
package com.github.libretube.api package com.github.libretube.api
import android.content.Context
import android.util.Log 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.DatabaseHolder.Companion.Database
import com.github.libretube.db.obj.LocalSubscription import com.github.libretube.db.obj.LocalSubscription
import com.github.libretube.extensions.TAG import com.github.libretube.extensions.TAG
import com.github.libretube.extensions.awaitQuery import com.github.libretube.extensions.awaitQuery
import com.github.libretube.extensions.query import com.github.libretube.extensions.query
import com.github.libretube.util.PreferenceHelper import com.github.libretube.util.PreferenceHelper
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch 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? { suspend fun isSubscribed(channelId: String): Boolean? {
if (PreferenceHelper.getToken() != "") { if (PreferenceHelper.getToken() != "") {
val isSubscribed = try { val isSubscribed = try {
@ -99,7 +123,7 @@ object SubscriptionHelper {
} }
} }
fun getLocalSubscriptions(): List<LocalSubscription> { private fun getLocalSubscriptions(): List<LocalSubscription> {
return awaitQuery { return awaitQuery {
Database.localSubscriptionDao().getAll() Database.localSubscriptionDao().getAll()
} }
@ -107,6 +131,30 @@ object SubscriptionHelper {
fun getFormattedLocalSubscriptions(): String { fun getFormattedLocalSubscriptions(): String {
val localSubscriptions = getLocalSubscriptions() val localSubscriptions = getLocalSubscriptions()
return localSubscriptions.map { it.channelId }.joinToString(",") return localSubscriptions.joinToString(",") { it.channelId }
}
suspend fun getSubscriptions(): List<Subscription> {
return if (PreferenceHelper.getToken() != "") {
RetrofitInstance.authApi.subscriptions(
PreferenceHelper.getToken()
)
} else {
RetrofitInstance.authApi.unauthenticatedSubscriptions(
getFormattedLocalSubscriptions()
)
}
}
suspend fun getFeed(): List<StreamItem> {
return if (PreferenceHelper.getToken() != "") {
RetrofitInstance.authApi.getFeed(
PreferenceHelper.getToken()
)
} else {
RetrofitInstance.authApi.getUnauthenticatedFeed(
getFormattedLocalSubscriptions()
)
}
} }
} }

View File

@ -12,5 +12,6 @@ data class Channel(
var nextpage: String? = null, var nextpage: String? = null,
var subscriberCount: Long = 0, var subscriberCount: Long = 0,
var verified: Boolean = false, var verified: Boolean = false,
var relatedStreams: List<StreamItem>? = null var relatedStreams: List<StreamItem>? = listOf(),
var tabs: List<ChannelTab>? = listOf()
) )

View File

@ -3,6 +3,7 @@ package com.github.libretube.api.obj
import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class Segments( data class ChannelTab(
val segments: MutableList<Segment> = arrayListOf() val name: String? = null,
val data: String? = null
) )

View File

@ -0,0 +1,6 @@
package com.github.libretube.api.obj
data class ChannelTabResponse(
val content: List<ContentItem> = listOf(),
val nextpage: String? = null
)

View File

@ -6,5 +6,5 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties
data class CommentsPage( data class CommentsPage(
val comments: MutableList<Comment> = arrayListOf(), val comments: MutableList<Comment> = arrayListOf(),
val disabled: Boolean? = null, val disabled: Boolean? = null,
val nextpage: String? = "" val nextpage: String? = null
) )

View File

@ -3,7 +3,7 @@ package com.github.libretube.api.obj
import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class SearchItem( data class ContentItem(
var url: String? = null, var url: String? = null,
var thumbnail: String? = null, var thumbnail: String? = null,
var uploaderName: String? = null, var uploaderName: String? = null,

View File

@ -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
)

View File

@ -17,5 +17,7 @@ data class PipedStream(
var indexEnd: Int? = null, var indexEnd: Int? = null,
var width: Int? = null, var width: Int? = null,
var height: Int? = null, var height: Int? = null,
var fps: Int? = null var fps: Int? = null,
val audioTrackName: String? = null,
val audioTrackId: String? = null
) )

View File

@ -7,5 +7,6 @@ data class Playlists(
var id: String? = null, var id: String? = null,
var name: String? = null, var name: String? = null,
var shortDescription: String? = null, var shortDescription: String? = null,
var thumbnail: String? = null var thumbnail: String? = null,
var videos: Long? = null
) )

View File

@ -4,8 +4,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class SearchResult( data class SearchResult(
val items: MutableList<SearchItem>? = arrayListOf(), val items: MutableList<ContentItem>? = arrayListOf(),
val nextpage: String? = "", val nextpage: String? = null,
val suggestion: String? = "", val suggestion: String? = "",
val corrected: Boolean? = null val corrected: Boolean? = null
) )

View File

@ -4,7 +4,13 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class Segment( data class Segment(
val UUID: String? = null,
val actionType: String? = null, val actionType: String? = null,
val category: String? = null, val category: String? = null,
val segment: List<Float>? = arrayListOf() val description: String? = null,
val locked: Int? = null,
val segment: List<Double> = listOf(),
val userID: String? = null,
val videoDuration: Double? = null,
val votes: Int? = null
) )

View File

@ -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<Segment> = listOf(),
val videoID: String? = null
)

View File

@ -9,7 +9,6 @@ const val GITHUB_API_URL = "https://api.github.com/repos/libre-tube/LibreTube/re
* Links for the about fragment * Links for the about fragment
*/ */
const val WEBSITE_URL = "https://libre-tube.github.io/" 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 GITHUB_URL = "https://github.com/libre-tube/LibreTube"
const val PIPED_GITHUB_URL = "https://github.com/TeamPiped/Piped" const val PIPED_GITHUB_URL = "https://github.com/TeamPiped/Piped"
const val WEBLATE_URL = "https://hosted.weblate.org/projects/libretube/libretube/" 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 * 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" const val YOUTUBE_FRONTEND_URL = "https://www.youtube.com"
/** /**

View File

@ -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
}

View File

@ -8,4 +8,6 @@ object IntentData {
const val timeStamp = "timeStamp" const val timeStamp = "timeStamp"
const val position = "position" const val position = "position"
const val fileName = "fileName" const val fileName = "fileName"
const val openQueueOnce = "openQueue"
const val playlistType = "playlistType"
} }

View File

@ -10,6 +10,7 @@ object PreferenceKeys {
const val AUTH_PREF_FILE = "auth" const val AUTH_PREF_FILE = "auth"
const val TOKEN = "token" const val TOKEN = "token"
const val USERNAME = "username" const val USERNAME = "username"
const val IMAGE_PROXY_URL = "image_proxy_url"
/** /**
* General * General
@ -20,7 +21,7 @@ object PreferenceKeys {
const val BREAK_REMINDER_TOGGLE = "break_reminder_toggle" const val BREAK_REMINDER_TOGGLE = "break_reminder_toggle"
const val BREAK_REMINDER = "break_reminder" const val BREAK_REMINDER = "break_reminder"
const val SAVE_FEED = "save_feed" const val SAVE_FEED = "save_feed"
const val NAVBAR_ITEMS = "nav_bar_items" const val NAVBAR_ITEMS = "navbar_items"
/** /**
* Appearance * Appearance
@ -33,7 +34,7 @@ object PreferenceKeys {
const val APP_ICON = "icon_change" const val APP_ICON = "icon_change"
const val LEGACY_SUBSCRIPTIONS = "legacy_subscriptions" const val LEGACY_SUBSCRIPTIONS = "legacy_subscriptions"
const val LEGACY_SUBSCRIPTIONS_COLUMNS = "legacy_subscriptions_columns" 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 NEW_VIDEOS_BADGE = "new_videos_badge"
const val PLAYLISTS_ORDER = "playlists_order" const val PLAYLISTS_ORDER = "playlists_order"
@ -77,13 +78,16 @@ object PreferenceKeys {
const val PICTURE_IN_PICTURE = "picture_in_picture" const val PICTURE_IN_PICTURE = "picture_in_picture"
const val PLAYER_RESIZE_MODE = "player_resize_mode" const val PLAYER_RESIZE_MODE = "player_resize_mode"
const val SB_SKIP_MANUALLY = "sb_skip_manually_key" const val SB_SKIP_MANUALLY = "sb_skip_manually_key"
const val LIMIT_HLS = "limit_hls" const val SB_SHOW_MARKERS = "sb_show_markers"
const val PROGRESSIVE_LOADING_INTERVAL_SIZE = "progressive_loading_interval" 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 * Background mode
*/ */
const val BACKGROUND_PLAYBACK_SPEED = "background_playback_speed" const val BACKGROUND_PLAYBACK_SPEED = "background_playback_speed"
const val NOTIFICATION_OPEN_QUEUE = "notification_open_queue"
/** /**
* Notifications * Notifications
@ -92,6 +96,10 @@ object PreferenceKeys {
const val CHECKING_FREQUENCY = "checking_frequency" const val CHECKING_FREQUENCY = "checking_frequency"
const val REQUIRED_NETWORK = "required_network" const val REQUIRED_NETWORK = "required_network"
const val LAST_STREAM_VIDEO_ID = "last_stream_video_id" 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 * Advanced
@ -103,6 +111,8 @@ object PreferenceKeys {
const val CLEAR_WATCH_HISTORY = "clear_watch_history" const val CLEAR_WATCH_HISTORY = "clear_watch_history"
const val CLEAR_WATCH_POSITIONS = "clear_watch_positions" const val CLEAR_WATCH_POSITIONS = "clear_watch_positions"
const val SHARE_WITH_TIME_CODE = "share_with_time_code" const val SHARE_WITH_TIME_CODE = "share_with_time_code"
const val CONFIRM_UNSUBSCRIBE = "confirm_unsubscribing"
const val CLEAR_BOOKMARKS = "clear_bookmarks"
/** /**
* History * History

View File

@ -1,7 +0,0 @@
package com.github.libretube.constants
object ShareObjectType {
const val VIDEO = 0
const val PLAYLIST = 1
const val CHANNEL = 2
}

View File

@ -1,14 +1,20 @@
package com.github.libretube.db package com.github.libretube.db
import androidx.room.AutoMigration
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import com.github.libretube.db.dao.CustomInstanceDao 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.LocalSubscriptionDao
import com.github.libretube.db.dao.PlaylistBookmarkDao
import com.github.libretube.db.dao.SearchHistoryDao import com.github.libretube.db.dao.SearchHistoryDao
import com.github.libretube.db.dao.WatchHistoryDao import com.github.libretube.db.dao.WatchHistoryDao
import com.github.libretube.db.dao.WatchPositionDao import com.github.libretube.db.dao.WatchPositionDao
import com.github.libretube.db.obj.CustomInstance 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.LocalSubscription
import com.github.libretube.db.obj.PlaylistBookmark
import com.github.libretube.db.obj.SearchHistoryItem import com.github.libretube.db.obj.SearchHistoryItem
import com.github.libretube.db.obj.WatchHistoryItem import com.github.libretube.db.obj.WatchHistoryItem
import com.github.libretube.db.obj.WatchPosition import com.github.libretube.db.obj.WatchPosition
@ -19,9 +25,16 @@ import com.github.libretube.db.obj.WatchPosition
WatchPosition::class, WatchPosition::class,
SearchHistoryItem::class, SearchHistoryItem::class,
CustomInstance::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() { abstract class AppDatabase : RoomDatabase() {
/** /**
@ -48,4 +61,14 @@ abstract class AppDatabase : RoomDatabase() {
* Local Subscriptions * Local Subscriptions
*/ */
abstract fun localSubscriptionDao(): LocalSubscriptionDao abstract fun localSubscriptionDao(): LocalSubscriptionDao
/**
* Bookmarked Playlists
*/
abstract fun playlistBookmarkDao(): PlaylistBookmarkDao
/**
* Local playlists
*/
abstract fun localPlaylistsDao(): LocalPlaylistsDao
} }

View File

@ -5,12 +5,13 @@ import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.db.DatabaseHolder.Companion.Database import com.github.libretube.db.DatabaseHolder.Companion.Database
import com.github.libretube.db.obj.SearchHistoryItem import com.github.libretube.db.obj.SearchHistoryItem
import com.github.libretube.db.obj.WatchHistoryItem 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.query
import com.github.libretube.extensions.toID import com.github.libretube.extensions.toID
import com.github.libretube.util.PreferenceHelper import com.github.libretube.util.PreferenceHelper
object DatabaseHelper { object DatabaseHelper {
private const val MAX_SEARCH_HISTORY_SIZE = 20
fun addToWatchHistory(videoId: String, streams: Streams) { fun addToWatchHistory(videoId: String, streams: Streams) {
val watchHistoryItem = WatchHistoryItem( val watchHistoryItem = WatchHistoryItem(
videoId, 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) { fun addToSearchHistory(searchHistoryItem: SearchHistoryItem) {
query { query {
Database.searchHistoryDao().insertAll(searchHistoryItem) Database.searchHistoryDao().insertAll(searchHistoryItem)
val maxHistorySize = 20
// delete the first watch history entry if the limit is reached // delete the first watch history entry if the limit is reached
val searchHistory = Database.searchHistoryDao().getAll() val searchHistory = Database.searchHistoryDao().getAll()
if (searchHistory.size > maxHistorySize) { if (searchHistory.size > MAX_SEARCH_HISTORY_SIZE) {
Database.searchHistoryDao() Database.searchHistoryDao()
.delete(searchHistory.first()) .delete(searchHistory.first())
} }

View File

@ -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<LocalPlaylistWithVideos>
@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)
}

View File

@ -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<PlaylistBookmark>
@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()
}

View File

@ -21,6 +21,9 @@ interface WatchPositionDao {
@Delete @Delete
fun delete(watchPosition: WatchPosition) fun delete(watchPosition: WatchPosition)
@Query("DELETE FROM watchHistoryItem WHERE videoId = :videoId")
fun deleteById(videoId: String)
@Query("DELETE FROM watchPosition") @Query("DELETE FROM watchPosition")
fun deleteAll() fun deleteAll()
} }

View File

@ -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
)

View File

@ -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
)

View File

@ -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<LocalPlaylistItem>
)

View File

@ -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
)

View File

@ -11,7 +11,7 @@ data class WatchHistoryItem(
@ColumnInfo val uploadDate: String? = null, @ColumnInfo val uploadDate: String? = null,
@ColumnInfo val uploader: String? = null, @ColumnInfo val uploader: String? = null,
@ColumnInfo val uploaderUrl: String? = null, @ColumnInfo val uploaderUrl: String? = null,
@ColumnInfo val uploaderAvatar: String? = null, @ColumnInfo var uploaderAvatar: String? = null,
@ColumnInfo val thumbnailUrl: String? = null, @ColumnInfo var thumbnailUrl: String? = null,
@ColumnInfo val duration: Long? = null @ColumnInfo val duration: Long? = null
) )

View File

@ -0,0 +1,11 @@
package com.github.libretube.enums
/**
* object for saving the download type
*/
enum class DownloadType {
AUDIO,
VIDEO,
AUDIO_VIDEO,
NONE
}

View File

@ -0,0 +1,18 @@
package com.github.libretube.enums
enum class PlaylistType {
/**
* Local playlist
*/
LOCAL,
/**
* Piped playlist
*/
PRIVATE,
/**
* YouTube playlist
*/
PUBLIC
}

View File

@ -0,0 +1,7 @@
package com.github.libretube.enums
enum class ShareObjectType {
VIDEO,
PLAYLIST,
CHANNEL
}

View File

@ -1,7 +0,0 @@
package com.github.libretube.extensions
import java.io.File
fun File.createDir() = apply {
if (!this.exists()) this.mkdirs()
}

View File

@ -3,15 +3,17 @@ package com.github.libretube.extensions
import java.math.BigDecimal import java.math.BigDecimal
import java.math.RoundingMode import java.math.RoundingMode
@Suppress("KotlinConstantConditions")
fun Long?.formatShort(): String = when { fun Long?.formatShort(): String = when {
this!! < 1000 -> { this == null -> (0).toString()
this < 1000 -> {
this.toString() this.toString()
} }
this in 1000..999999 -> { this in (1000..999999) -> {
val decimal = BigDecimal(this / 1000).setScale(0, RoundingMode.HALF_EVEN) val decimal = BigDecimal(this / 1000).setScale(0, RoundingMode.HALF_EVEN)
decimal.toString() + "K" decimal.toString() + "K"
} }
this in 1000000..10000000 -> { this in (1000000..10000000) -> {
val decimal = BigDecimal(this / 1000000).setScale(0, RoundingMode.HALF_EVEN) val decimal = BigDecimal(this / 1000000).setScale(0, RoundingMode.HALF_EVEN)
decimal.toString() + "M" decimal.toString() + "M"
} }

View File

@ -0,0 +1,7 @@
package com.github.libretube.extensions
fun <T> MutableList<T>.move(oldPosition: Int, newPosition: Int) {
val item = this.get(oldPosition)
this.removeAt(oldPosition)
this.add(newPosition, item)
}

View File

@ -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()
}

View File

@ -0,0 +1,13 @@
package com.github.libretube.ui.extensions
import android.os.Build
import android.os.Bundle
import java.io.Serializable
inline fun <reified T : Serializable> 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
}
}

View File

@ -3,9 +3,8 @@ package com.github.libretube.extensions
/** /**
* format a Piped route to an ID * format a Piped route to an ID
*/ */
fun Any.toID(): String { fun String.toID(): String {
return this return this
.toString()
.replace("/watch?v=", "") // videos .replace("/watch?v=", "") // videos
.replace("/channel/", "") // channels .replace("/channel/", "") // channels
.replace("/playlist?list=", "") // playlists .replace("/playlist?list=", "") // playlists

View File

@ -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
)
}

View File

@ -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
)
}

View File

@ -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))
}

View File

@ -1,5 +0,0 @@
package com.github.libretube.models.interfaces
interface DoubleTapInterface {
fun onEvent(x: Float)
}

View File

@ -1,7 +0,0 @@
package com.github.libretube.models.interfaces
interface PlayerOptionsInterface {
fun onCaptionClicked()
fun onQualityClicked()
}

View File

@ -1,7 +1,9 @@
package com.github.libretube.obj package com.github.libretube.obj
import com.github.libretube.db.obj.CustomInstance 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.LocalSubscription
import com.github.libretube.db.obj.PlaylistBookmark
import com.github.libretube.db.obj.SearchHistoryItem import com.github.libretube.db.obj.SearchHistoryItem
import com.github.libretube.db.obj.WatchHistoryItem import com.github.libretube.db.obj.WatchHistoryItem
import com.github.libretube.db.obj.WatchPosition import com.github.libretube.db.obj.WatchPosition
@ -12,5 +14,7 @@ data class BackupFile(
var searchHistory: List<SearchHistoryItem>? = null, var searchHistory: List<SearchHistoryItem>? = null,
var localSubscriptions: List<LocalSubscription>? = null, var localSubscriptions: List<LocalSubscription>? = null,
var customInstances: List<CustomInstance>? = null, var customInstances: List<CustomInstance>? = null,
var playlistBookmarks: List<PlaylistBookmark>? = null,
var localPlaylists: List<LocalPlaylistWithVideos>? = null,
var preferences: List<PreferenceItem>? = null var preferences: List<PreferenceItem>? = null
) )

View File

@ -3,5 +3,6 @@ package com.github.libretube.obj
data class BottomSheetItem( data class BottomSheetItem(
val title: String, val title: String,
val drawable: Int? = null, val drawable: Int? = null,
val currentValue: String? = null val getCurrent: () -> String? = { null },
val onClick: () -> Unit = {}
) )

View File

@ -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)
}

View File

@ -6,7 +6,6 @@ import com.github.libretube.api.obj.Streams
data class DownloadedFile( data class DownloadedFile(
val name: String, val name: String,
val size: Long, val size: Long,
val type: Int,
var metadata: Streams? = null, var metadata: Streams? = null,
var thumbnail: Bitmap? = null var thumbnail: Bitmap? = null
) )

View File

@ -1,7 +0,0 @@
package com.github.libretube.obj
data class NavBarItem(
val id: Int = 0,
val title: String = "",
var isEnabled: Boolean = true
)

View File

@ -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
)

View File

@ -0,0 +1,7 @@
package com.github.libretube.obj
data class VideoResolution(
val name: String,
val resolution: Int? = null,
val adaptiveSourceUrl: String? = null
)

View File

@ -9,23 +9,24 @@ import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper import android.os.Looper
import android.util.Log
import android.widget.Toast import android.widget.Toast
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import com.github.libretube.R import com.github.libretube.R
import com.github.libretube.api.RetrofitInstance import com.github.libretube.api.RetrofitInstance
import com.github.libretube.api.obj.Segment 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.api.obj.Streams
import com.github.libretube.constants.BACKGROUND_CHANNEL_ID import com.github.libretube.constants.BACKGROUND_CHANNEL_ID
import com.github.libretube.constants.IntentData import com.github.libretube.constants.IntentData
import com.github.libretube.constants.PLAYER_NOTIFICATION_ID import com.github.libretube.constants.PLAYER_NOTIFICATION_ID
import com.github.libretube.constants.PreferenceKeys import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.db.DatabaseHelper import com.github.libretube.db.DatabaseHolder.Companion.Database
import com.github.libretube.db.DatabaseHolder import com.github.libretube.db.obj.WatchPosition
import com.github.libretube.enums.PlaylistType
import com.github.libretube.extensions.awaitQuery import com.github.libretube.extensions.awaitQuery
import com.github.libretube.extensions.query
import com.github.libretube.extensions.toID 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.NowPlayingNotification
import com.github.libretube.util.PlayerHelper import com.github.libretube.util.PlayerHelper
import com.github.libretube.util.PlayingQueue import com.github.libretube.util.PlayingQueue
@ -53,6 +54,7 @@ class BackgroundMode : Service() {
*PlaylistId for autoplay *PlaylistId for autoplay
*/ */
private var playlistId: String? = null private var playlistId: String? = null
private var playlistType: PlaylistType? = null
/** /**
* The response that gets when called the Api. * The response that gets when called the Api.
@ -73,23 +75,13 @@ class BackgroundMode : Service() {
/** /**
* SponsorBlock Segment data * SponsorBlock Segment data
*/ */
private var segmentData: Segments? = null private var segmentData: SegmentData? = null
/** /**
* [Notification] for the player * [Notification] for the player
*/ */
private lateinit var nowPlayingNotification: NowPlayingNotification 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 * Autoplay Preference
*/ */
@ -100,18 +92,21 @@ class BackgroundMode : Service() {
*/ */
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
if (Build.VERSION.SDK_INT >= 26) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channelId = BACKGROUND_CHANNEL_ID
val channel = NotificationChannel( val channel = NotificationChannel(
channelId, BACKGROUND_CHANNEL_ID,
"Background Service", "Background Service",
NotificationManager.IMPORTANCE_DEFAULT NotificationManager.IMPORTANCE_DEFAULT
) )
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel) 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)) .setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.playingOnBackground)).build() .setContentText(getString(R.string.playingOnBackground))
.build()
startForeground(PLAYER_NOTIFICATION_ID, notification) startForeground(PLAYER_NOTIFICATION_ID, notification)
} }
} }
@ -122,20 +117,21 @@ class BackgroundMode : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
try { try {
// clear the playing queue // clear the playing queue
PlayingQueue.clear() PlayingQueue.resetToDefaults()
// get the intent arguments // get the intent arguments
videoId = intent?.getStringExtra(IntentData.videoId)!! videoId = intent?.getStringExtra(IntentData.videoId)!!
playlistId = intent.getStringExtra(IntentData.playlistId) playlistId = intent.getStringExtra(IntentData.playlistId)
val position = intent.getLongExtra(IntentData.position, 0L) val position = intent.getLongExtra(IntentData.position, 0L)
// initialize the playlist autoPlay Helper
autoPlayHelper = AutoPlayHelper(playlistId)
// play the audio in the background // play the audio in the background
loadAudio(videoId, position) loadAudio(videoId, position)
updateWatchPosition() PlayingQueue.setOnQueueTapListener { streamItem ->
streamItem.url?.toID()?.let { playNextVideo(it) }
}
if (PlayerHelper.watchPositionsEnabled) updateWatchPosition()
} catch (e: Exception) { } catch (e: Exception) {
onDestroy() onDestroy()
} }
@ -143,7 +139,11 @@ class BackgroundMode : Service() {
} }
private fun updateWatchPosition() { 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) handler.postDelayed(this::updateWatchPosition, 500)
} }
@ -154,8 +154,6 @@ class BackgroundMode : Service() {
videoId: String, videoId: String,
seekToPosition: Long = 0 seekToPosition: Long = 0
) { ) {
// append the video to the playing queue
PlayingQueue.add(videoId)
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
try { try {
streams = RetrofitInstance.api.getStreams(videoId) streams = RetrofitInstance.api.getStreams(videoId)
@ -163,6 +161,21 @@ class BackgroundMode : Service() {
return@launch 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 { handler.post {
playAudio(seekToPosition) playAudio(seekToPosition)
} }
@ -172,8 +185,6 @@ class BackgroundMode : Service() {
private fun playAudio( private fun playAudio(
seekToPosition: Long seekToPosition: Long
) { ) {
PlayingQueue.updateCurrent(videoId)
initializePlayer() initializePlayer()
setMediaItem() setMediaItem()
@ -194,9 +205,8 @@ class BackgroundMode : Service() {
} else if (PlayerHelper.watchPositionsEnabled) { } else if (PlayerHelper.watchPositionsEnabled) {
try { try {
val watchPosition = awaitQuery { val watchPosition = awaitQuery {
DatabaseHolder.Database.watchPositionDao().findById(videoId) Database.watchPositionDao().findById(videoId)
} }
Log.e("position", watchPosition.toString())
streams?.duration?.let { streams?.duration?.let {
if (watchPosition != null && watchPosition.position < it * 1000 * 0.9) { if (watchPosition != null && watchPosition.position < it * 1000 * 0.9) {
player?.seekTo(watchPosition.position) player?.seekTo(watchPosition.position)
@ -215,8 +225,6 @@ class BackgroundMode : Service() {
player?.setPlaybackSpeed(playbackSpeed) player?.setPlaybackSpeed(playbackSpeed)
fetchSponsorBlockSegments() 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) * Plays the first related video to the current (used when the playback of the current video ended)
*/ */
private fun playNextVideo() { private fun playNextVideo(nextId: String? = null) {
if (nextStreamId == null || nextStreamId == videoId) return val nextVideo = nextId ?: PlayingQueue.getNext()
val nextQueueVideo = PlayingQueue.getNext()
if (nextQueueVideo != null) nextStreamId = nextQueueVideo
// play new video on background // play new video on background
this.videoId = nextStreamId!! if (nextVideo != null) {
this.videoId = nextVideo
}
this.segmentData = null this.segmentData = null
loadAudio(videoId) loadAudio(videoId)
} }
@ -322,16 +314,15 @@ class BackgroundMode : Service() {
*/ */
private fun fetchSponsorBlockSegments() { private fun fetchSponsorBlockSegments() {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
kotlin.runCatching { runCatching {
val categories = PlayerHelper.getSponsorBlockCategories() val categories = PlayerHelper.getSponsorBlockCategories()
if (categories.size > 0) { if (categories.isEmpty()) return@runCatching
segmentData = segmentData =
RetrofitInstance.api.getSegments( RetrofitInstance.api.getSegments(
videoId, videoId,
ObjectMapper().writeValueAsString(categories) ObjectMapper().writeValueAsString(categories)
) )
checkForSegments() checkForSegments()
}
} }
} }
} }
@ -345,7 +336,7 @@ class BackgroundMode : Service() {
if (segmentData == null || segmentData!!.segments.isEmpty()) return if (segmentData == null || segmentData!!.segments.isEmpty()) return
segmentData!!.segments.forEach { segment: Segment -> 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 segmentEnd = (segment.segment[1] * 1000f).toLong()
val currentPosition = player?.currentPosition val currentPosition = player?.currentPosition
if (currentPosition in segmentStart until segmentEnd) { if (currentPosition in segmentStart until segmentEnd) {
@ -367,7 +358,7 @@ class BackgroundMode : Service() {
*/ */
override fun onDestroy() { override fun onDestroy() {
// clear the playing queue // clear the playing queue
PlayingQueue.clear() PlayingQueue.resetToDefaults()
if (this::nowPlayingNotification.isInitialized) nowPlayingNotification.destroySelfAndPlayer() if (this::nowPlayingNotification.isInitialized) nowPlayingNotification.destroySelfAndPlayer()

View File

@ -15,7 +15,7 @@ import com.github.libretube.R
import com.github.libretube.constants.DOWNLOAD_CHANNEL_ID import com.github.libretube.constants.DOWNLOAD_CHANNEL_ID
import com.github.libretube.constants.DOWNLOAD_FAILURE_NOTIFICATION_ID import com.github.libretube.constants.DOWNLOAD_FAILURE_NOTIFICATION_ID
import com.github.libretube.constants.DOWNLOAD_SUCCESS_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.extensions.TAG
import com.github.libretube.util.DownloadHelper import com.github.libretube.util.DownloadHelper
import java.io.File import java.io.File
@ -25,7 +25,7 @@ class DownloadService : Service() {
private lateinit var videoName: String private lateinit var videoName: String
private lateinit var videoUrl: String private lateinit var videoUrl: String
private lateinit var audioUrl: String private lateinit var audioUrl: String
private var downloadType: Int = 3 private var downloadType: DownloadType = DownloadType.NONE
private var videoDownloadId: Long? = null private var videoDownloadId: Long? = null
private var audioDownloadId: Long? = null private var audioDownloadId: Long? = null
@ -63,8 +63,8 @@ class DownloadService : Service() {
private fun downloadManager() { private fun downloadManager() {
// initialize and create the directories to download into // initialize and create the directories to download into
val videoDownloadDir = DownloadHelper.getVideoDir(this) val videoDownloadDir = DownloadHelper.getDownloadDir(this, DownloadHelper.VIDEO_DIR)
val audioDownloadDir = DownloadHelper.getAudioDir(this) val audioDownloadDir = DownloadHelper.getDownloadDir(this, DownloadHelper.AUDIO_DIR)
// start download // start download
try { try {
@ -74,7 +74,7 @@ class DownloadService : Service() {
) )
if (downloadType in listOf(DownloadType.VIDEO, DownloadType.AUDIO_VIDEO)) { if (downloadType in listOf(DownloadType.VIDEO, DownloadType.AUDIO_VIDEO)) {
videoDownloadId = downloadManagerRequest( videoDownloadId = downloadManagerRequest(
getString(R.string.video), "[${getString(R.string.video)}] $videoName",
getString(R.string.downloading), getString(R.string.downloading),
videoUrl, videoUrl,
Uri.fromFile( Uri.fromFile(
@ -84,7 +84,7 @@ class DownloadService : Service() {
} }
if (downloadType in listOf(DownloadType.AUDIO, DownloadType.AUDIO_VIDEO)) { if (downloadType in listOf(DownloadType.AUDIO, DownloadType.AUDIO_VIDEO)) {
audioDownloadId = downloadManagerRequest( audioDownloadId = downloadManagerRequest(
getString(R.string.audio), "[${getString(R.string.audio)}] $videoName",
getString(R.string.downloading), getString(R.string.downloading),
audioUrl, audioUrl,
Uri.fromFile( Uri.fromFile(

View File

@ -11,6 +11,7 @@ import android.os.Environment
import android.os.IBinder import android.os.IBinder
import android.widget.Toast import android.widget.Toast
import com.github.libretube.R import com.github.libretube.R
import com.github.libretube.util.DownloadHelper
import java.io.File import java.io.File
class UpdateService : Service() { class UpdateService : Service() {
@ -28,9 +29,7 @@ class UpdateService : Service() {
} }
private fun downloadApk(downloadUrl: String) { private fun downloadApk(downloadUrl: String) {
val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) file = File(getDownloadDirectory(), "release.apk")
// val dir = applicationContext.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
file = File(dir, "release.apk")
val request: DownloadManager.Request = val request: DownloadManager.Request =
DownloadManager.Request(Uri.parse(downloadUrl)) 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() { override fun onDestroy() {
unregisterReceiver(onDownloadComplete) unregisterReceiver(onDownloadComplete)
super.onDestroy() super.onDestroy()

View File

@ -14,10 +14,10 @@ import android.view.View
import android.view.WindowInsets import android.view.WindowInsets
import android.view.WindowInsetsController import android.view.WindowInsetsController
import android.view.WindowManager import android.view.WindowManager
import android.widget.Toast
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.core.view.children
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.findNavController import androidx.navigation.findNavController
@ -27,18 +27,20 @@ import com.github.libretube.constants.IntentData
import com.github.libretube.constants.PreferenceKeys import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.databinding.ActivityMainBinding import com.github.libretube.databinding.ActivityMainBinding
import com.github.libretube.extensions.toID 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.services.ClosingService
import com.github.libretube.ui.base.BaseActivity import com.github.libretube.ui.base.BaseActivity
import com.github.libretube.ui.dialogs.ErrorDialog import com.github.libretube.ui.dialogs.ErrorDialog
import com.github.libretube.ui.fragments.PlayerFragment 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.NavBarHelper
import com.github.libretube.util.NetworkHelper import com.github.libretube.util.NetworkHelper
import com.github.libretube.util.PlayingQueue
import com.github.libretube.util.PreferenceHelper import com.github.libretube.util.PreferenceHelper
import com.github.libretube.util.ThemeHelper import com.github.libretube.util.ThemeHelper
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.elevation.SurfaceColors import com.google.android.material.elevation.SurfaceColors
class MainActivity : BaseActivity() { class MainActivity : BaseActivity() {
@ -47,21 +49,17 @@ class MainActivity : BaseActivity() {
lateinit var navController: NavController lateinit var navController: NavController
private var startFragmentId = R.id.homeFragment private var startFragmentId = R.id.homeFragment
var autoRotationEnabled = false
val autoRotationEnabled = PreferenceHelper.getBoolean(PreferenceKeys.AUTO_ROTATION, false)
lateinit var searchView: SearchView lateinit var searchView: SearchView
lateinit var searchItem: MenuItem
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
autoRotationEnabled = PreferenceHelper.getBoolean(PreferenceKeys.AUTO_ROTATION, false)
// enable auto rotation if turned on // enable auto rotation if turned on
requestedOrientation = if (autoRotationEnabled) { requestOrientationChange()
ActivityInfo.SCREEN_ORIENTATION_USER
} else {
ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT
}
// start service that gets called on closure // start service that gets called on closure
try { try {
@ -93,8 +91,12 @@ class MainActivity : BaseActivity() {
// sets the navigation bar color to the previously calculated color // sets the navigation bar color to the previously calculated color
window.navigationBarColor = color window.navigationBarColor = color
// save start tab fragment id // save start tab fragment id and apply navbar style
startFragmentId = NavBarHelper.applyNavBarStyle(binding.bottomNav) startFragmentId = try {
NavBarHelper.applyNavBarStyle(binding.bottomNav)
} catch (e: Exception) {
R.id.homeFragment
}
// set default tab as start fragment // set default tab as start fragment
navController.graph.setStartDestination(startFragmentId) navController.graph.setStartDestination(startFragmentId)
@ -104,18 +106,16 @@ class MainActivity : BaseActivity() {
binding.bottomNav.setOnApplyWindowInsetsListener(null) binding.bottomNav.setOnApplyWindowInsetsListener(null)
binding.bottomNav.setOnItemSelectedListener { // Prevent duplicate entries into backstack, if selected item and current
// clear backstack if it's the start fragment // visible fragment is different, then navigate to selected item.
if (startFragmentId == it.itemId) navController.backQueue.clear() binding.bottomNav.setOnItemReselectedListener {
if (it.itemId != navController.currentDestination?.id) {
if (it.itemId == R.id.subscriptionsFragment) { navigateToBottomSelectedItem(it)
binding.bottomNav.removeBadge(R.id.subscriptionsFragment)
} }
}
removeSearchFocus() binding.bottomNav.setOnItemSelectedListener {
navigateToBottomSelectedItem(it)
// navigate to the selected fragment
navController.navigate(it.itemId)
false false
} }
@ -127,7 +127,7 @@ class MainActivity : BaseActivity() {
val log = PreferenceHelper.getErrorLog() val log = PreferenceHelper.getErrorLog()
if (log != "") ErrorDialog().show(supportFragmentManager, null) if (log != "") ErrorDialog().show(supportFragmentManager, null)
setupBreakReminder() BreakReminder.setupBreakReminder(applicationContext)
setupSubscriptionsBadge() setupSubscriptionsBadge()
@ -143,56 +143,38 @@ class MainActivity : BaseActivity() {
} }
} }
if (navController.currentDestination?.id == startFragmentId) { when (navController.currentDestination?.id) {
moveTaskToBack(true) startFragmentId -> {
} else { moveTaskToBack(true)
navController.popBackStack() }
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() { fun requestOrientationChange() {
if (!PreferenceHelper.getBoolean( requestedOrientation = if (autoRotationEnabled) {
PreferenceKeys.BREAK_REMINDER_TOGGLE, ActivityInfo.SCREEN_ORIENTATION_USER
false } else {
) ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT
) {
return
} }
val breakReminderPref = PreferenceHelper.getString( }
PreferenceKeys.BREAK_REMINDER,
"0" override fun onPrepareOptionsMenu(menu: Menu?): Boolean {
) menu?.findItem(R.id.action_queue)?.isVisible = PlayingQueue.isNotEmpty()
if (!breakReminderPref.all { Character.isDigit(it) } || return super.onPrepareOptionsMenu(menu)
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
)
} }
/** /**
@ -227,6 +209,8 @@ class MainActivity : BaseActivity() {
private fun removeSearchFocus() { private fun removeSearchFocus() {
searchView.setQuery("", false) searchView.setQuery("", false)
searchView.clearFocus() searchView.clearFocus()
searchView.isIconified = true
searchItem.collapseActionView()
searchView.onActionViewCollapsed() searchView.onActionViewCollapsed()
} }
@ -236,27 +220,31 @@ class MainActivity : BaseActivity() {
// stuff for the search in the topBar // stuff for the search in the topBar
val searchItem = menu.findItem(R.id.action_search) val searchItem = menu.findItem(R.id.action_search)
this.searchItem = searchItem
searchView = searchItem.actionView as SearchView searchView = searchItem.actionView as SearchView
val searchViewModel = ViewModelProvider(this)[SearchViewModel::class.java] 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 { searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean { override fun onQueryTextSubmit(query: String?): Boolean {
val bundle = Bundle() val bundle = Bundle()
bundle.putString("query", query) bundle.putString("query", query)
navController.navigate(R.id.searchResultFragment, bundle) navController.navigate(R.id.searchResultFragment, bundle)
searchViewModel.setQuery("") searchViewModel.setQuery("")
searchView.clearFocus()
return true return true
} }
override fun onQueryTextChange(newText: String?): Boolean { 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 // prevent malicious navigation when the search view is getting collapsed
if (navController.currentDestination?.id in listOf( if (navController.currentDestination?.id in listOf(
R.id.searchResultFragment, R.id.searchResultFragment,
@ -279,6 +267,36 @@ class MainActivity : BaseActivity() {
return true 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) return super.onCreateOptionsMenu(menu)
} }
@ -302,16 +320,14 @@ class MainActivity : BaseActivity() {
startActivity(communityIntent) startActivity(communityIntent)
true true
} }
R.id.action_queue -> {
PlayingQueueSheet().show(supportFragmentManager, null)
true
}
else -> super.onOptionsItemSelected(item) 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() { private fun loadIntentData() {
intent?.getStringExtra(IntentData.channelId)?.let { intent?.getStringExtra(IntentData.channelId)?.let {
navController.navigate( navController.navigate(
@ -334,6 +350,20 @@ class MainActivity : BaseActivity() {
intent?.getStringExtra(IntentData.videoId)?.let { intent?.getStringExtra(IntentData.videoId)?.let {
loadVideo(it, intent?.getLongExtra(IntentData.timeStamp, 0L)) 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?) { private fun loadVideo(videoId: String, timeStamp: Long?) {
@ -359,7 +389,7 @@ class MainActivity : BaseActivity() {
transitionToStart() transitionToStart()
} }
} }
}, 100) }, 300)
} }
private fun minimizePlayer() { 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() { override fun onUserLeaveHint() {
super.onUserLeaveHint() super.onUserLeaveHint()
supportFragmentManager.fragments.forEach { fragment -> supportFragmentManager.fragments.forEach { fragment ->

View File

@ -68,28 +68,26 @@ class OfflinePlayerActivity : BaseActivity() {
} }
binding.player.initialize( binding.player.initialize(
supportFragmentManager,
null, null,
binding.doubleTapOverlay.binding, binding.doubleTapOverlay.binding,
null null
) )
} }
private fun File.toUri(): Uri? {
return if (this.exists()) Uri.fromFile(this) else null
}
private fun playVideo() { private fun playVideo() {
val videoDownloadDir = DownloadHelper.getVideoDir(this) val videoUri = File(
val videoFile = File( DownloadHelper.getDownloadDir(this, DownloadHelper.VIDEO_DIR),
videoDownloadDir,
fileName fileName
) ).toUri()
val audioDownloadDir = DownloadHelper.getAudioDir(this) val audioUri = File(
val audioFile = File( DownloadHelper.getDownloadDir(this, DownloadHelper.AUDIO_DIR),
audioDownloadDir,
fileName fileName
) ).toUri()
val videoUri = if (videoFile.exists()) Uri.fromFile(videoFile) else null
val audioUri = if (audioFile.exists()) Uri.fromFile(audioFile) else null
setMediaSource( setMediaSource(
videoUri, videoUri,

View File

@ -10,6 +10,7 @@ import com.github.libretube.constants.IntentData
import com.github.libretube.extensions.TAG import com.github.libretube.extensions.TAG
import com.github.libretube.ui.base.BaseActivity import com.github.libretube.ui.base.BaseActivity
import com.github.libretube.util.NavigationHelper import com.github.libretube.util.NavigationHelper
import kotlin.time.Duration
class RouterActivity : BaseActivity() { class RouterActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -75,14 +76,14 @@ class RouterActivity : BaseActivity() {
intent.putExtra(IntentData.videoId, videoId) intent.putExtra(IntentData.videoId, videoId)
uri.getQueryParameter("t") uri.getQueryParameter("t")
?.let { intent.putExtra(IntentData.timeStamp, it.toLong()) } ?.let { intent.putExtra(IntentData.timeStamp, parseTimestamp(it)) }
} }
else -> { else -> {
val videoId = uri.path!!.replace("/", "") val videoId = uri.path!!.replace("/", "")
intent.putExtra(IntentData.videoId, videoId) intent.putExtra(IntentData.videoId, videoId)
uri.getQueryParameter("t") uri.getQueryParameter("t")
?.let { intent.putExtra(IntentData.timeStamp, it.toLong()) } ?.let { intent.putExtra(IntentData.timeStamp, parseTimestamp(it)) }
} }
} }
return intent return intent
@ -99,4 +100,12 @@ class RouterActivity : BaseActivity() {
) )
this.finishAndRemoveTask() this.finishAndRemoveTask()
} }
private fun parseTimestamp(t: String): Long? {
if (t.all { c -> c.isDigit() }) {
return t.toLong()
}
return Duration.parseOrNull(t)?.inWholeSeconds
}
} }

View File

@ -24,16 +24,17 @@ class BottomSheetAdapter(
override fun onBindViewHolder(holder: BottomSheetViewHolder, position: Int) { override fun onBindViewHolder(holder: BottomSheetViewHolder, position: Int) {
val item = items[position] val item = items[position]
holder.binding.apply { holder.binding.apply {
val current = item.getCurrent()
title.text = 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) { if (item.drawable != null) {
drawable.setImageResource(item.drawable) drawable.setImageResource(item.drawable)
} else { } else {
drawable.visibility = drawable.visibility = View.GONE
View.GONE
} }
root.setOnClickListener { root.setOnClickListener {
item.onClick.invoke()
listener.invoke(position) listener.invoke(position)
} }
} }

View File

@ -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<StreamItem>,
private val childFragmentManager: FragmentManager,
private val showChannelInfo: Boolean = false
) :
RecyclerView.Adapter<ChannelViewHolder>() {
override fun getItemCount(): Int {
return videoFeed.size
}
fun updateItems(newItems: List<StreamItem>) {
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!!)
}
}
}

View File

@ -1,9 +1,11 @@
package com.github.libretube.ui.adapters package com.github.libretube.ui.adapters
import android.graphics.Color import android.graphics.Color
import android.text.format.DateUtils
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.github.libretube.api.obj.ChapterSegment
import com.github.libretube.databinding.ChapterColumnBinding import com.github.libretube.databinding.ChapterColumnBinding
import com.github.libretube.ui.viewholders.ChaptersViewHolder import com.github.libretube.ui.viewholders.ChaptersViewHolder
import com.github.libretube.util.ImageHelper import com.github.libretube.util.ImageHelper
@ -11,7 +13,7 @@ import com.github.libretube.util.ThemeHelper
import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.ExoPlayer
class ChaptersAdapter( class ChaptersAdapter(
private val chapters: List<com.github.libretube.api.obj.ChapterSegment>, private val chapters: List<ChapterSegment>,
private val exoPlayer: ExoPlayer private val exoPlayer: ExoPlayer
) : RecyclerView.Adapter<ChaptersViewHolder>() { ) : RecyclerView.Adapter<ChaptersViewHolder>() {
private var selectedPosition = 0 private var selectedPosition = 0
@ -27,15 +29,15 @@ class ChaptersAdapter(
holder.binding.apply { holder.binding.apply {
ImageHelper.loadImage(chapter.image, chapterImage) ImageHelper.loadImage(chapter.image, chapterImage)
chapterTitle.text = chapter.title chapterTitle.text = chapter.title
timeStamp.text = chapter.start?.let { DateUtils.formatElapsedTime(it) }
if (selectedPosition == position) { val color = if (selectedPosition == position) {
// get the color for highlighted controls ThemeHelper.getThemeColor(root.context, android.R.attr.colorControlHighlight)
val color =
ThemeHelper.getThemeColor(root.context, android.R.attr.colorControlHighlight)
chapterLL.setBackgroundColor(color)
} else { } else {
chapterLL.setBackgroundColor(Color.TRANSPARENT) Color.TRANSPARENT
} }
chapterLL.setBackgroundColor(color)
root.setOnClickListener { root.setOnClickListener {
updateSelectedPosition(position) updateSelectedPosition(position)
val chapterStart = chapter.start!! * 1000 // s -> ms val chapterStart = chapter.start!! * 1000 // s -> ms

View File

@ -5,6 +5,7 @@ import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Button
import android.widget.Toast import android.widget.Toast
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView 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.ClipboardHelper
import com.github.libretube.util.ImageHelper import com.github.libretube.util.ImageHelper
import com.github.libretube.util.NavigationHelper import com.github.libretube.util.NavigationHelper
import com.github.libretube.util.TextUtils
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -60,7 +62,7 @@ class CommentsAdapter(
root.scaleY = 0.9f 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() commentText.text = comment.commentText.toString()
ImageHelper.loadImage(comment.thumbnail, commentorImage) ImageHelper.loadImage(comment.thumbnail, commentorImage)
@ -71,8 +73,7 @@ class CommentsAdapter(
if (comment.hearted == true) heartedImageView.visibility = View.VISIBLE if (comment.hearted == true) heartedImageView.visibility = View.VISIBLE
if (comment.repliesPage != null) repliesAvailable.visibility = View.VISIBLE if (comment.repliesPage != null) repliesAvailable.visibility = View.VISIBLE
if ((comment.replyCount ?: -1L) > 0L) { if ((comment.replyCount ?: -1L) > 0L) {
repliesCount.text = repliesCount.text = comment.replyCount?.formatShort()
comment.replyCount?.formatShort()
} }
commentorImage.setOnClickListener { commentorImage.setOnClickListener {
@ -89,30 +90,7 @@ class CommentsAdapter(
repliesRecView.adapter = repliesAdapter repliesRecView.adapter = repliesAdapter
if (!isRepliesAdapter && comment.repliesPage != null) { if (!isRepliesAdapter && comment.repliesPage != null) {
root.setOnClickListener { root.setOnClickListener {
when { showMoreReplies(comment.repliesPage, showMore, repliesAdapter)
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()
}
} }
} }
@ -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 { override fun getItemCount(): Int {
return comments.size return comments.size
} }

View File

@ -13,6 +13,7 @@ import com.github.libretube.obj.DownloadedFile
import com.github.libretube.ui.activities.OfflinePlayerActivity import com.github.libretube.ui.activities.OfflinePlayerActivity
import com.github.libretube.ui.viewholders.DownloadsViewHolder import com.github.libretube.ui.viewholders.DownloadsViewHolder
import com.github.libretube.util.DownloadHelper import com.github.libretube.util.DownloadHelper
import com.github.libretube.util.TextUtils
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.io.File import java.io.File
@ -39,7 +40,7 @@ class DownloadsAdapter(
uploaderName.text = it.uploader uploaderName.text = it.uploader
videoInfo.text = it.views.formatShort() + " " + videoInfo.text = it.views.formatShort() + " " +
root.context.getString(R.string.views_placeholder) + root.context.getString(R.string.views_placeholder) +
"" + it.uploadDate TextUtils.SEPARATOR + it.uploadDate
} }
thumbnailImage.setImageBitmap(file.thumbnail) thumbnailImage.setImageBitmap(file.thumbnail)
@ -60,8 +61,8 @@ class DownloadsAdapter(
) { _, index -> ) { _, index ->
when (index) { when (index) {
0 -> { 0 -> {
val audioDir = DownloadHelper.getAudioDir(root.context) val audioDir = DownloadHelper.getDownloadDir(root.context, DownloadHelper.AUDIO_DIR)
val videoDir = DownloadHelper.getVideoDir(root.context) val videoDir = DownloadHelper.getDownloadDir(root.context, DownloadHelper.VIDEO_DIR)
listOf(audioDir, videoDir).forEach { listOf(audioDir, videoDir).forEach {
val f = File(it, file.name) val f = File(it, file.name)

View File

@ -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<IconsSheetViewHolder>() {
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
)
}
}

View File

@ -1,16 +1,16 @@
package com.github.libretube.ui.adapters package com.github.libretube.ui.adapters
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.github.libretube.R import com.github.libretube.R
import com.github.libretube.databinding.NavOptionsItemBinding import com.github.libretube.databinding.NavOptionsItemBinding
import com.github.libretube.obj.NavBarItem
import com.github.libretube.ui.viewholders.NavBarOptionsViewHolder import com.github.libretube.ui.viewholders.NavBarOptionsViewHolder
class NavBarOptionsAdapter( class NavBarOptionsAdapter(
val items: MutableList<NavBarItem> val items: MutableList<MenuItem>
) : RecyclerView.Adapter<NavBarOptionsViewHolder>() { ) : RecyclerView.Adapter<NavBarOptionsViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NavBarOptionsViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NavBarOptionsViewHolder {
@ -30,9 +30,9 @@ class NavBarOptionsAdapter(
val item = items[position] val item = items[position]
holder.binding.apply { holder.binding.apply {
title.text = item.title title.text = item.title
checkbox.isChecked = item.isEnabled checkbox.isChecked = item.isVisible
checkbox.setOnClickListener { checkbox.setOnClickListener {
if (!checkbox.isChecked && getEnabledItemsCount() < 2) { if (!checkbox.isChecked && getVisibleItemsCount() < 2) {
checkbox.isChecked = true checkbox.isChecked = true
Toast.makeText( Toast.makeText(
root.context, root.context,
@ -41,12 +41,12 @@ class NavBarOptionsAdapter(
).show() ).show()
return@setOnClickListener return@setOnClickListener
} }
items[position].isEnabled = checkbox.isChecked items[position].isVisible = checkbox.isChecked
} }
} }
} }
private fun getEnabledItemsCount(): Int { private fun getVisibleItemsCount(): Int {
return items.filter { it.isEnabled }.size return items.filter { it.isVisible }.size
} }
} }

View File

@ -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<PlayingQueueViewHolder>() {
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)
}
}
}
}

View File

@ -1,42 +1,41 @@
package com.github.libretube.ui.adapters package com.github.libretube.ui.adapters
import android.app.Activity import android.app.Activity
import android.content.Context
import android.util.Log import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.RecyclerView 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.databinding.PlaylistRowBinding
import com.github.libretube.enums.PlaylistType
import com.github.libretube.extensions.TAG 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.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.sheets.VideoOptionsBottomSheet
import com.github.libretube.ui.viewholders.PlaylistViewHolder import com.github.libretube.ui.viewholders.PlaylistViewHolder
import com.github.libretube.util.ImageHelper import com.github.libretube.util.ImageHelper
import com.github.libretube.util.NavigationHelper import com.github.libretube.util.NavigationHelper
import com.github.libretube.util.PreferenceHelper
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import retrofit2.HttpException
import java.io.IOException import java.io.IOException
class PlaylistAdapter( class PlaylistAdapter(
private val videoFeed: MutableList<com.github.libretube.api.obj.StreamItem>, private val videoFeed: MutableList<StreamItem>,
private val playlistId: String, private val playlistId: String,
private val isOwner: Boolean, private val playlistType: PlaylistType
private val activity: Activity,
private val childFragmentManager: FragmentManager
) : RecyclerView.Adapter<PlaylistViewHolder>() { ) : RecyclerView.Adapter<PlaylistViewHolder>() {
override fun getItemCount(): Int { override fun getItemCount(): Int {
return videoFeed.size return videoFeed.size
} }
fun updateItems(newItems: List<com.github.libretube.api.obj.StreamItem>) { fun updateItems(newItems: List<StreamItem>) {
val oldSize = videoFeed.size val oldSize = videoFeed.size
videoFeed.addAll(newItems) videoFeed.addAll(newItems)
notifyItemRangeInserted(oldSize, videoFeed.size) notifyItemRangeInserted(oldSize, videoFeed.size)
@ -59,40 +58,37 @@ class PlaylistAdapter(
NavigationHelper.navigateVideo(root.context, streamItem.url, playlistId) NavigationHelper.navigateVideo(root.context, streamItem.url, playlistId)
} }
val videoId = streamItem.url!!.toID() val videoId = streamItem.url!!.toID()
val videoName = streamItem.title!!
root.setOnLongClickListener { root.setOnLongClickListener {
VideoOptionsBottomSheet(videoId) VideoOptionsBottomSheet(videoId, videoName)
.show(childFragmentManager, VideoOptionsBottomSheet::class.java.name) .show(
(root.context as BaseActivity).supportFragmentManager,
VideoOptionsBottomSheet::class.java.name
)
true true
} }
if (isOwner) { if (playlistType != PlaylistType.PUBLIC) {
deletePlaylist.visibility = View.VISIBLE deletePlaylist.visibility = View.VISIBLE
deletePlaylist.setOnClickListener { deletePlaylist.setOnClickListener {
removeFromPlaylist(position) removeFromPlaylist(root.context, position)
} }
} }
watchProgress.setWatchProgressLength(videoId, streamItem.duration!!) watchProgress.setWatchProgressLength(videoId, streamItem.duration!!)
} }
} }
fun removeFromPlaylist(position: Int) { fun removeFromPlaylist(context: Context, position: Int) {
videoFeed.removeAt(position) videoFeed.removeAt(position)
activity.runOnUiThread { notifyDataSetChanged() } (context as Activity).runOnUiThread {
notifyItemRemoved(position)
notifyItemRangeChanged(position, itemCount)
}
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
try { try {
RetrofitInstance.authApi.removeFromPlaylist( PlaylistsHelper.removeFromPlaylist(playlistId, position)
PreferenceHelper.getToken(),
com.github.libretube.api.obj.PlaylistId(
playlistId = playlistId,
index = position
)
)
} catch (e: IOException) { } catch (e: IOException) {
println(e) Log.e(TAG(), e.toString())
Log.e(TAG(), "IOException, you might not have internet connection")
return@launch
} catch (e: HttpException) {
Log.e(TAG(), "HttpException, unexpected response")
return@launch return@launch
} }
} }

View File

@ -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<PlaylistBookmark>,
private val bookmarkMode: BookmarkMode = BookmarkMode.FRAGMENT
) : RecyclerView.Adapter<PlaylistBookmarkViewHolder>() {
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
}
}
}

View File

@ -1,38 +1,29 @@
package com.github.libretube.ui.adapters package com.github.libretube.ui.adapters
import android.app.Activity
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.github.libretube.R 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.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.sheets.PlaylistOptionsBottomSheet
import com.github.libretube.ui.viewholders.PlaylistsViewHolder import com.github.libretube.ui.viewholders.PlaylistsViewHolder
import com.github.libretube.util.ImageHelper import com.github.libretube.util.ImageHelper
import com.github.libretube.util.NavigationHelper 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( class PlaylistsAdapter(
private val playlists: MutableList<com.github.libretube.api.obj.Playlists>, private val playlists: MutableList<Playlists>,
private val childFragmentManager: FragmentManager, private val playlistType: PlaylistType
private val activity: Activity
) : RecyclerView.Adapter<PlaylistsViewHolder>() { ) : RecyclerView.Adapter<PlaylistsViewHolder>() {
override fun getItemCount(): Int { override fun getItemCount(): Int {
return playlists.size return playlists.size
} }
fun updateItems(newItems: List<com.github.libretube.api.obj.Playlists>) { fun updateItems(newItems: List<Playlists>) {
val oldSize = playlists.size val oldSize = playlists.size
playlists.addAll(newItems) playlists.addAll(newItems)
notifyItemRangeInserted(oldSize, playlists.size) notifyItemRangeInserted(oldSize, playlists.size)
@ -55,61 +46,37 @@ class PlaylistsAdapter(
ImageHelper.loadImage(playlist.thumbnail, playlistThumbnail) ImageHelper.loadImage(playlist.thumbnail, playlistThumbnail)
} }
playlistTitle.text = playlist.name playlistTitle.text = playlist.name
videoCount.text = playlist.videos.toString()
deletePlaylist.setOnClickListener { deletePlaylist.setOnClickListener {
val builder = MaterialAlertDialogBuilder(root.context) DeletePlaylistDialog(playlist.id!!, playlistType) {
builder.setTitle(R.string.deletePlaylist) playlists.removeAt(position)
builder.setMessage(R.string.areYouSure) (root.context as BaseActivity).runOnUiThread {
builder.setPositiveButton(R.string.yes) { _, _ -> notifyItemRemoved(position)
PreferenceHelper.getToken() notifyItemRangeChanged(position, itemCount)
deletePlaylist(playlist.id!!, position) }
} }.show(
builder.setNegativeButton(R.string.cancel, null) (root.context as BaseActivity).supportFragmentManager,
builder.show() null
)
} }
root.setOnClickListener { root.setOnClickListener {
NavigationHelper.navigatePlaylist(root.context, playlist.id, true) NavigationHelper.navigatePlaylist(root.context, playlist.id, playlistType)
} }
root.setOnLongClickListener { root.setOnLongClickListener {
val playlistOptionsDialog = PlaylistOptionsBottomSheet( val playlistOptionsDialog = PlaylistOptionsBottomSheet(
playlistId = playlist.id!!, playlistId = playlist.id!!,
isOwner = true playlistName = playlist.name!!,
playlistType = playlistType
) )
playlistOptionsDialog.show( playlistOptionsDialog.show(
childFragmentManager, (root.context as BaseActivity).supportFragmentManager,
PlaylistOptionsBottomSheet::class.java.name PlaylistOptionsBottomSheet::class.java.name
) )
true 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())
}
}
}
} }

View File

@ -4,34 +4,32 @@ import android.annotation.SuppressLint
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.github.libretube.R import com.github.libretube.R
import com.github.libretube.api.SubscriptionHelper import com.github.libretube.api.obj.ContentItem
import com.github.libretube.api.obj.SearchItem
import com.github.libretube.databinding.ChannelRowBinding 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.databinding.VideoRowBinding
import com.github.libretube.enums.PlaylistType
import com.github.libretube.extensions.formatShort 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.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.PlaylistOptionsBottomSheet
import com.github.libretube.ui.sheets.VideoOptionsBottomSheet import com.github.libretube.ui.sheets.VideoOptionsBottomSheet
import com.github.libretube.ui.viewholders.SearchViewHolder import com.github.libretube.ui.viewholders.SearchViewHolder
import com.github.libretube.util.ImageHelper import com.github.libretube.util.ImageHelper
import com.github.libretube.util.NavigationHelper import com.github.libretube.util.NavigationHelper
import kotlinx.coroutines.CoroutineScope import com.github.libretube.util.TextUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class SearchAdapter( class SearchAdapter(
private val searchItems: MutableList<SearchItem>, private val searchItems: MutableList<ContentItem>
private val childFragmentManager: FragmentManager
) : ) :
RecyclerView.Adapter<SearchViewHolder>() { RecyclerView.Adapter<SearchViewHolder>() {
fun updateItems(newItems: List<SearchItem>) { fun updateItems(newItems: List<ContentItem>) {
val searchItemsSize = searchItems.size val searchItemsSize = searchItems.size
searchItems.addAll(newItems) searchItems.addAll(newItems)
notifyItemRangeInserted(searchItemsSize, newItems.size) notifyItemRangeInserted(searchItemsSize, newItems.size)
@ -52,7 +50,7 @@ class SearchAdapter(
ChannelRowBinding.inflate(layoutInflater, parent, false) ChannelRowBinding.inflate(layoutInflater, parent, false)
) )
2 -> SearchViewHolder( 2 -> SearchViewHolder(
PlaylistSearchRowBinding.inflate(layoutInflater, parent, false) PlaylistsRowBinding.inflate(layoutInflater, parent, false)
) )
else -> throw IllegalArgumentException("Invalid type") 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 { binding.apply {
ImageHelper.loadImage(item.thumbnail, thumbnail) ImageHelper.loadImage(item.thumbnail, thumbnail)
thumbnailDuration.setFormattedDuration(item.duration!!) thumbnailDuration.setFormattedDuration(item.duration!!)
@ -100,12 +98,13 @@ class SearchAdapter(
NavigationHelper.navigateVideo(root.context, item.url) NavigationHelper.navigateVideo(root.context, item.url)
} }
val videoId = item.url!!.toID() val videoId = item.url!!.toID()
val videoName = item.title!!
root.setOnLongClickListener { root.setOnLongClickListener {
VideoOptionsBottomSheet(videoId) VideoOptionsBottomSheet(videoId, videoName)
.show(childFragmentManager, VideoOptionsBottomSheet::class.java.name) .show((root.context as BaseActivity).supportFragmentManager, VideoOptionsBottomSheet::class.java.name)
true true
} }
channelImage.setOnClickListener { channelContainer.setOnClickListener {
NavigationHelper.navigateChannel(root.context, item.uploaderUrl) NavigationHelper.navigateChannel(root.context, item.uploaderUrl)
} }
watchProgress.setWatchProgressLength(videoId, item.duration!!) watchProgress.setWatchProgressLength(videoId, item.duration!!)
@ -114,7 +113,7 @@ class SearchAdapter(
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
private fun bindChannel( private fun bindChannel(
item: SearchItem, item: ContentItem,
binding: ChannelRowBinding binding: ChannelRowBinding
) { ) {
binding.apply { binding.apply {
@ -123,66 +122,33 @@ class SearchAdapter(
searchViews.text = root.context.getString( searchViews.text = root.context.getString(
R.string.subscribers, R.string.subscribers,
item.subscribers.formatShort() 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 { root.setOnClickListener {
NavigationHelper.navigateChannel(root.context, item.url) NavigationHelper.navigateChannel(root.context, item.url)
} }
val channelId = item.url!!.toID()
isSubscribed(channelId, binding) binding.searchSubButton.setupSubscriptionButton(item.url?.toID(), item.name?.toID())
}
}
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
}
}
} }
} }
private fun bindPlaylist( private fun bindPlaylist(
item: SearchItem, item: ContentItem,
binding: PlaylistSearchRowBinding binding: PlaylistsRowBinding
) { ) {
binding.apply { binding.apply {
ImageHelper.loadImage(item.thumbnail, searchThumbnail) ImageHelper.loadImage(item.thumbnail, playlistThumbnail)
if (item.videos?.toInt() != -1) searchPlaylistNumber.text = item.videos.toString() if (item.videos?.toInt() != -1) videoCount.text = item.videos.toString()
searchDescription.text = item.name playlistTitle.text = item.name
searchName.text = item.uploaderName playlistDescription.text = item.uploaderName
if (item.videos?.toInt() != -1) {
searchPlaylistVideos.text =
root.context.getString(R.string.videoCount, item.videos.toString())
}
root.setOnClickListener { root.setOnClickListener {
NavigationHelper.navigatePlaylist(root.context, item.url, false) NavigationHelper.navigatePlaylist(root.context, item.url, PlaylistType.PUBLIC)
} }
deletePlaylist.visibility = View.GONE
root.setOnLongClickListener { root.setOnLongClickListener {
val playlistId = item.url!!.toID() val playlistId = item.url!!.toID()
PlaylistOptionsBottomSheet(playlistId, false) val playlistName = item.name!!
.show(childFragmentManager, PlaylistOptionsBottomSheet::class.java.name) PlaylistOptionsBottomSheet(playlistId, playlistName, PlaylistType.PUBLIC)
.show((root.context as BaseActivity).supportFragmentManager, PlaylistOptionsBottomSheet::class.java.name)
true true
} }
} }

View File

@ -30,6 +30,9 @@ class SearchSuggestionsAdapter(
root.setOnClickListener { root.setOnClickListener {
searchView.setQuery(suggestion, true) searchView.setQuery(suggestion, true)
} }
arrow.setOnClickListener {
searchView.setQuery(suggestion, false)
}
} }
} }
} }

View File

@ -3,16 +3,17 @@ package com.github.libretube.ui.adapters
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.github.libretube.R import com.github.libretube.api.obj.Subscription
import com.github.libretube.api.SubscriptionHelper
import com.github.libretube.databinding.ChannelSubscriptionRowBinding import com.github.libretube.databinding.ChannelSubscriptionRowBinding
import com.github.libretube.extensions.toID import com.github.libretube.extensions.toID
import com.github.libretube.ui.extensions.setupSubscriptionButton
import com.github.libretube.ui.viewholders.SubscriptionChannelViewHolder import com.github.libretube.ui.viewholders.SubscriptionChannelViewHolder
import com.github.libretube.util.ImageHelper import com.github.libretube.util.ImageHelper
import com.github.libretube.util.NavigationHelper import com.github.libretube.util.NavigationHelper
class SubscriptionChannelAdapter(private val subscriptions: MutableList<com.github.libretube.api.obj.Subscription>) : class SubscriptionChannelAdapter(
RecyclerView.Adapter<SubscriptionChannelViewHolder>() { private val subscriptions: MutableList<Subscription>
) : RecyclerView.Adapter<SubscriptionChannelViewHolder>() {
override fun getItemCount(): Int { override fun getItemCount(): Int {
return subscriptions.size return subscriptions.size
@ -27,27 +28,20 @@ class SubscriptionChannelAdapter(private val subscriptions: MutableList<com.gith
override fun onBindViewHolder(holder: SubscriptionChannelViewHolder, position: Int) { override fun onBindViewHolder(holder: SubscriptionChannelViewHolder, position: Int) {
val subscription = subscriptions[position] val subscription = subscriptions[position]
var subscribed = true
holder.binding.apply { holder.binding.apply {
subscriptionChannelName.text = subscription.name subscriptionChannelName.text = subscription.name
ImageHelper.loadImage(subscription.avatar, subscriptionChannelImage) ImageHelper.loadImage(subscription.avatar, subscriptionChannelImage)
root.setOnClickListener { root.setOnClickListener {
NavigationHelper.navigateChannel(root.context, subscription.url) NavigationHelper.navigateChannel(root.context, subscription.url)
} }
subscriptionSubscribe.setOnClickListener { subscriptionSubscribe.setupSubscriptionButton(
val channelId = subscription.url!!.toID() subscription.url?.toID(),
if (subscribed) { subscription.name,
subscriptionSubscribe.text = root.context.getString(R.string.subscribe) notificationBell,
SubscriptionHelper.unsubscribe(channelId) true
subscribed = false )
} else {
subscriptionSubscribe.text =
root.context.getString(R.string.unsubscribe)
SubscriptionHelper.subscribe(channelId)
subscribed = true
}
}
} }
} }
} }

View File

@ -1,79 +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.databinding.TrendingRowBinding
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.sheets.VideoOptionsBottomSheet
import com.github.libretube.ui.viewholders.SubscriptionViewHolder
import com.github.libretube.util.ImageHelper
import com.github.libretube.util.NavigationHelper
import org.chromium.base.ContextUtils.getApplicationContext
class TrendingAdapter(
private val streamItems: List<com.github.libretube.api.obj.StreamItem>,
private val childFragmentManager: FragmentManager,
private val showAllAtOne: Boolean = true
) : RecyclerView.Adapter<SubscriptionViewHolder>() {
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!!)
}
}
}

View File

@ -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<StreamItem>,
private val showAllAtOnce: Boolean = true,
private val forceMode: ForceMode = ForceMode.NONE
) : RecyclerView.Adapter<VideosViewHolder>() {
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<StreamItem>) {
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()
)
}
}
}
}

View File

@ -2,26 +2,28 @@ package com.github.libretube.ui.adapters
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.github.libretube.databinding.WatchHistoryRowBinding 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.db.obj.WatchHistoryItem
import com.github.libretube.extensions.setFormattedDuration import com.github.libretube.extensions.query
import com.github.libretube.extensions.setWatchProgressLength 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.sheets.VideoOptionsBottomSheet
import com.github.libretube.ui.viewholders.WatchHistoryViewHolder import com.github.libretube.ui.viewholders.WatchHistoryViewHolder
import com.github.libretube.util.ImageHelper import com.github.libretube.util.ImageHelper
import com.github.libretube.util.NavigationHelper import com.github.libretube.util.NavigationHelper
class WatchHistoryAdapter( class WatchHistoryAdapter(
private val watchHistory: MutableList<WatchHistoryItem>, private val watchHistory: MutableList<WatchHistoryItem>
private val childFragmentManager: FragmentManager
) : ) :
RecyclerView.Adapter<WatchHistoryViewHolder>() { RecyclerView.Adapter<WatchHistoryViewHolder>() {
fun removeFromWatchHistory(position: Int) { fun removeFromWatchHistory(position: Int) {
DatabaseHelper.removeFromWatchHistory(position) query {
DatabaseHolder.Database.watchHistoryDao().delete(watchHistory[position])
}
watchHistory.removeAt(position) watchHistory.removeAt(position)
notifyItemRemoved(position) notifyItemRemoved(position)
notifyItemRangeChanged(position, itemCount) notifyItemRangeChanged(position, itemCount)
@ -55,8 +57,8 @@ class WatchHistoryAdapter(
NavigationHelper.navigateVideo(root.context, video.videoId) NavigationHelper.navigateVideo(root.context, video.videoId)
} }
root.setOnLongClickListener { root.setOnLongClickListener {
VideoOptionsBottomSheet(video.videoId) VideoOptionsBottomSheet(video.videoId, video.title!!)
.show(childFragmentManager, VideoOptionsBottomSheet::class.java.name) .show((root.context as BaseActivity).supportFragmentManager, VideoOptionsBottomSheet::class.java.name)
true true
} }

View File

@ -1,10 +1,7 @@
package com.github.libretube.ui.dialogs package com.github.libretube.ui.dialogs
import android.app.Dialog import android.app.Dialog
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log import android.util.Log
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.Toast import android.widget.Toast
@ -13,36 +10,34 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.github.libretube.R import com.github.libretube.R
import com.github.libretube.api.RetrofitInstance import com.github.libretube.api.PlaylistsHelper
import com.github.libretube.api.obj.PlaylistId
import com.github.libretube.constants.IntentData
import com.github.libretube.databinding.DialogAddtoplaylistBinding import com.github.libretube.databinding.DialogAddtoplaylistBinding
import com.github.libretube.extensions.TAG import com.github.libretube.extensions.TAG
import com.github.libretube.models.PlaylistViewModel import com.github.libretube.extensions.toastFromMainThread
import com.github.libretube.util.PreferenceHelper import com.github.libretube.ui.models.PlaylistViewModel
import com.github.libretube.util.ThemeHelper import com.github.libretube.util.ThemeHelper
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch 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 lateinit var binding: DialogAddtoplaylistBinding
private val viewModel: PlaylistViewModel by activityViewModels() private val viewModel: PlaylistViewModel by activityViewModels()
private lateinit var videoId: String
private lateinit var token: String
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
videoId = arguments?.getString(IntentData.videoId)!!
binding = DialogAddtoplaylistBinding.inflate(layoutInflater) binding = DialogAddtoplaylistBinding.inflate(layoutInflater)
binding.title.text = ThemeHelper.getStyledAppName(requireContext()) binding.title.text = ThemeHelper.getStyledAppName(requireContext())
token = PreferenceHelper.getToken() binding.createPlaylist.setOnClickListener {
CreatePlaylistDialog {
fetchPlaylists()
}.show(childFragmentManager, null)
}
if (token != "") fetchPlaylists() fetchPlaylists()
return MaterialAlertDialogBuilder(requireContext()) return MaterialAlertDialogBuilder(requireContext())
.setView(binding.root) .setView(binding.root)
@ -52,16 +47,11 @@ class AddToPlaylistDialog : DialogFragment() {
private fun fetchPlaylists() { private fun fetchPlaylists() {
lifecycleScope.launchWhenCreated { lifecycleScope.launchWhenCreated {
val response = try { val response = try {
RetrofitInstance.authApi.playlists(token) PlaylistsHelper.getPlaylists()
} catch (e: IOException) { } catch (e: Exception) {
println(e) Log.e(TAG(), e.toString())
Log.e(TAG(), "IOException, you might not have internet connection")
Toast.makeText(context, R.string.unknown_error, Toast.LENGTH_SHORT).show() Toast.makeText(context, R.string.unknown_error, Toast.LENGTH_SHORT).show()
return@launchWhenCreated 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()) { if (response.isNotEmpty()) {
val names = response.map { it.name } val names = response.map { it.name }
@ -75,8 +65,7 @@ class AddToPlaylistDialog : DialogFragment() {
var selectionIndex = 0 var selectionIndex = 0
response.forEachIndexed { index, playlist -> response.forEachIndexed { index, playlist ->
if (playlist.id == viewModel.lastSelectedPlaylistId) { if (playlist.id == viewModel.lastSelectedPlaylistId) {
selectionIndex = selectionIndex = index
index
} }
} }
binding.playlistsSpinner.setSelection(selectionIndex) binding.playlistsSpinner.setSelection(selectionIndex)
@ -96,38 +85,19 @@ class AddToPlaylistDialog : DialogFragment() {
private fun addToPlaylist(playlistId: String) { private fun addToPlaylist(playlistId: String) {
val appContext = context?.applicationContext ?: return val appContext = context?.applicationContext ?: return
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
val response = try { val success = try {
RetrofitInstance.authApi.addToPlaylist( PlaylistsHelper.addToPlaylist(playlistId, videoId)
token, } catch (e: Exception) {
PlaylistId(playlistId, videoId) Log.e(TAG(), e.toString())
) appContext.toastFromMainThread(R.string.unknown_error)
} 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)
return@launch return@launch
} }
toastFromMainThread( appContext.toastFromMainThread(
appContext, if (success) R.string.added_to_playlist else R.string.fail
if (response.message == "ok") 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) { private fun Fragment?.runOnUiThread(action: () -> Unit) {
this ?: return this ?: return
if (!isAdded) return // Fragment not attached to an Activity if (!isAdded) return // Fragment not attached to an Activity

View File

@ -2,9 +2,11 @@ package com.github.libretube.ui.dialogs
import android.app.Dialog import android.app.Dialog
import android.os.Bundle import android.os.Bundle
import androidx.annotation.StringRes
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import com.github.libretube.R import com.github.libretube.R
import com.github.libretube.db.DatabaseHolder.Companion.Database 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.BackupFile
import com.github.libretube.obj.PreferenceItem import com.github.libretube.obj.PreferenceItem
import com.github.libretube.util.PreferenceHelper import com.github.libretube.util.PreferenceHelper
@ -13,21 +15,56 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
class BackupDialog( class BackupDialog(
private val createBackupFile: (BackupFile) -> Unit private val createBackupFile: (BackupFile) -> Unit
) : DialogFragment() { ) : 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 { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val backupOptionNames = listOf( val backupOptions = listOf(
R.string.watch_history, BackupOption.WatchHistory,
R.string.watch_positions, BackupOption.WatchPositions,
R.string.search_history, BackupOption.SearchHistory,
R.string.local_subscriptions, BackupOption.LocalSubscriptions,
R.string.backup_customInstances, BackupOption.CustomInstances,
R.string.preferences 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()) return MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.backup) .setTitle(R.string.backup)
@ -36,39 +73,12 @@ class BackupDialog(
} }
.setNegativeButton(R.string.cancel, null) .setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.backup) { _, _ -> .setPositiveButton(R.string.backup) { _, _ ->
val thread = Thread { val backupFile = BackupFile()
if (selected[0]) { awaitQuery {
backupFile.watchHistory = backupOptions.forEachIndexed { index, option ->
Database.watchHistoryDao().getAll() if (selected[index]) option.onSelected.invoke(backupFile)
}
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
)
}
} }
} }
thread.start()
thread.join()
createBackupFile(backupFile) createBackupFile(backupFile)
} }
.create() .create()

View File

@ -2,23 +2,18 @@ package com.github.libretube.ui.dialogs
import android.app.Dialog import android.app.Dialog
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.github.libretube.R 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.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.github.libretube.util.ThemeHelper
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import retrofit2.HttpException
import java.io.IOException
class CreatePlaylistDialog : DialogFragment() { class CreatePlaylistDialog(
private var token: String = "" private val onSuccess: () -> Unit = {}
) : DialogFragment() {
private lateinit var binding: DialogCreatePlaylistBinding private lateinit var binding: DialogCreatePlaylistBinding
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
@ -30,14 +25,17 @@ class CreatePlaylistDialog : DialogFragment() {
dismiss() dismiss()
} }
token = PreferenceHelper.getToken()
binding.createNewPlaylist.setOnClickListener { binding.createNewPlaylist.setOnClickListener {
// avoid creating the same playlist multiple times by spamming the button // avoid creating the same playlist multiple times by spamming the button
binding.createNewPlaylist.setOnClickListener(null) binding.createNewPlaylist.setOnClickListener(null)
val listName = binding.playlistName.text.toString() val listName = binding.playlistName.text.toString()
if (listName != "") { if (listName != "") {
createPlaylist(listName) lifecycleScope.launchWhenCreated {
PlaylistsHelper.createPlaylist(listName, requireContext().applicationContext) {
onSuccess.invoke()
dismiss()
}
}
} else { } else {
Toast.makeText(context, R.string.emptyPlaylistName, Toast.LENGTH_LONG).show() Toast.makeText(context, R.string.emptyPlaylistName, Toast.LENGTH_LONG).show()
} }
@ -47,38 +45,4 @@ class CreatePlaylistDialog : DialogFragment() {
.setView(binding.root) .setView(binding.root)
.show() .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()
}
}
} }

View File

@ -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())
}
}
}
}

View File

@ -62,7 +62,7 @@ class NavBarOptionsDialog : DialogFragment() {
.setTitle(R.string.navigation_bar) .setTitle(R.string.navigation_bar)
.setView(binding.root) .setView(binding.root)
.setPositiveButton(R.string.okay) { _, _ -> .setPositiveButton(R.string.okay) { _, _ ->
NavBarHelper.setNavBarItems(adapter.items) NavBarHelper.setNavBarItems(adapter.items, requireContext())
RequireRestartDialog() RequireRestartDialog()
.show(requireParentFragment().childFragmentManager, null) .show(requireParentFragment().childFragmentManager, null)
} }

View File

@ -8,18 +8,19 @@ import androidx.fragment.app.DialogFragment
import com.github.libretube.R import com.github.libretube.R
import com.github.libretube.constants.PIPED_FRONTEND_URL import com.github.libretube.constants.PIPED_FRONTEND_URL
import com.github.libretube.constants.PreferenceKeys import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.constants.ShareObjectType
import com.github.libretube.constants.YOUTUBE_FRONTEND_URL import com.github.libretube.constants.YOUTUBE_FRONTEND_URL
import com.github.libretube.databinding.DialogShareBinding import com.github.libretube.databinding.DialogShareBinding
import com.github.libretube.db.DatabaseHolder.Companion.Database import com.github.libretube.db.DatabaseHolder.Companion.Database
import com.github.libretube.enums.ShareObjectType
import com.github.libretube.extensions.awaitQuery import com.github.libretube.extensions.awaitQuery
import com.github.libretube.obj.ShareData
import com.github.libretube.util.PreferenceHelper import com.github.libretube.util.PreferenceHelper
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
class ShareDialog( class ShareDialog(
private val id: String, private val id: String,
private val shareObjectType: Int, private val shareObjectType: ShareObjectType,
private val position: Long? = null private val shareData: ShareData
) : DialogFragment() { ) : DialogFragment() {
private var binding: DialogShareBinding? = null private var binding: DialogShareBinding? = null
@ -29,11 +30,11 @@ class ShareDialog(
getString(R.string.youtube) getString(R.string.youtube)
) )
val instanceUrl = getCustomInstanceFrontendUrl() val instanceUrl = getCustomInstanceFrontendUrl()
val shareableTitle = getShareableTitle(shareData)
// add instanceUrl option if custom instance frontend url available // add instanceUrl option if custom instance frontend url available
if (instanceUrl != "") shareOptions += getString(R.string.instance) if (instanceUrl != "") shareOptions += getString(R.string.instance)
if (shareObjectType == ShareObjectType.VIDEO && position != null) { if (shareObjectType == ShareObjectType.VIDEO) {
setupTimeStampBinding() setupTimeStampBinding()
} }
@ -55,7 +56,7 @@ class ShareDialog(
} }
var url = "$host$path" 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}" url += "&t=${binding!!.timeStamp.text}"
} }
@ -63,6 +64,7 @@ class ShareDialog(
intent.apply { intent.apply {
action = Intent.ACTION_SEND action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, url) putExtra(Intent.EXTRA_TEXT, url)
putExtra(Intent.EXTRA_SUBJECT, shareableTitle)
type = "text/plain" type = "text/plain"
} }
context?.startActivity( context?.startActivity(
@ -81,8 +83,9 @@ class ShareDialog(
) )
binding!!.timeCodeSwitch.setOnCheckedChangeListener { _, isChecked -> binding!!.timeCodeSwitch.setOnCheckedChangeListener { _, isChecked ->
binding!!.timeStampLayout.visibility = if (isChecked) View.VISIBLE else View.GONE 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 if (binding!!.timeCodeSwitch.isChecked) binding!!.timeStampLayout.visibility = View.VISIBLE
} }
@ -104,4 +107,18 @@ class ShareDialog(
} }
return "" return ""
} }
private fun getShareableTitle(shareData: ShareData): String {
shareData.apply {
currentChannel?.let {
return it
}
currentVideo?.let {
return it
}
currentPlaylist?.let {
return it
}
}
return ""
}
} }

View File

@ -1,4 +1,4 @@
package com.github.libretube.extensions package com.github.libretube.ui.extensions
import android.text.format.DateUtils import android.text.format.DateUtils
import android.widget.TextView import android.widget.TextView

View File

@ -1,9 +1,10 @@
package com.github.libretube.extensions package com.github.libretube.ui.extensions
import android.view.View import android.view.View
import android.view.ViewTreeObserver import android.view.ViewTreeObserver
import android.widget.LinearLayout import android.widget.LinearLayout
import com.github.libretube.db.DatabaseHolder.Companion.Database import com.github.libretube.db.DatabaseHolder.Companion.Database
import com.github.libretube.extensions.awaitQuery
/** /**
* shows the already watched time under the video * shows the already watched time under the video

View File

@ -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)
}
}

View File

@ -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
}
}
}

View File

@ -0,0 +1,5 @@
package com.github.libretube.ui.extensions
fun <T> List<T>.withMaxSize(maxSize: Int): List<T> {
return this.filterIndexed { index, _ -> index < maxSize }
}

View File

@ -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
}
}

View File

@ -6,21 +6,30 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.TextView import android.widget.TextView
import androidx.core.view.children
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.github.libretube.R import com.github.libretube.R
import com.github.libretube.api.RetrofitInstance import com.github.libretube.api.RetrofitInstance
import com.github.libretube.api.SubscriptionHelper import com.github.libretube.api.SubscriptionHelper
import com.github.libretube.api.obj.ChannelTab
import com.github.libretube.constants.IntentData import com.github.libretube.constants.IntentData
import com.github.libretube.constants.ShareObjectType
import com.github.libretube.databinding.FragmentChannelBinding import com.github.libretube.databinding.FragmentChannelBinding
import com.github.libretube.enums.ShareObjectType
import com.github.libretube.extensions.TAG import com.github.libretube.extensions.TAG
import com.github.libretube.extensions.formatShort import com.github.libretube.extensions.formatShort
import com.github.libretube.extensions.toID 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.base.BaseFragment
import com.github.libretube.ui.dialogs.ShareDialog import com.github.libretube.ui.dialogs.ShareDialog
import com.github.libretube.ui.extensions.setupSubscriptionButton
import com.github.libretube.util.ImageHelper import com.github.libretube.util.ImageHelper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import retrofit2.HttpException import retrofit2.HttpException
import java.io.IOException import java.io.IOException
@ -29,12 +38,22 @@ class ChannelFragment : BaseFragment() {
private var channelId: String? = null private var channelId: String? = null
private var channelName: String? = null private var channelName: String? = null
private var nextPage: String? = null private var nextPage: String? = null
private var channelAdapter: ChannelAdapter? = null private var channelAdapter: VideosAdapter? = null
private var isLoading = true private var isLoading = true
private var isSubscribed: Boolean? = false 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
arguments?.let { arguments?.let {
@ -63,7 +82,9 @@ class ChannelFragment : BaseFragment() {
binding.channelRefresh.isRefreshing = true binding.channelRefresh.isRefreshing = true
fetchChannel() fetchChannel()
} }
refreshChannel() refreshChannel()
binding.channelRefresh.setOnRefreshListener { binding.channelRefresh.setOnRefreshListener {
refreshChannel() refreshChannel()
} }
@ -73,11 +94,10 @@ class ChannelFragment : BaseFragment() {
if (binding.channelScrollView.getChildAt(0).bottom if (binding.channelScrollView.getChildAt(0).bottom
== (binding.channelScrollView.height + binding.channelScrollView.scrollY) == (binding.channelScrollView.height + binding.channelScrollView.scrollY)
) { ) {
// scroll view is at bottom try {
if (nextPage != null && !isLoading) { onScrollEnd.invoke()
isLoading = true } catch (e: Exception) {
binding.channelRefresh.isRefreshing = true Log.e("tabs failed", e.toString())
fetchChannelNextPage()
} }
} }
} }
@ -102,30 +122,26 @@ class ChannelFragment : BaseFragment() {
} }
// needed if the channel gets loaded by the ID // needed if the channel gets loaded by the ID
channelId = response.id channelId = response.id
channelName = response.name
val shareData = ShareData(currentChannel = response.name)
onScrollEnd = {
fetchChannelNextPage()
}
// fetch and update the subscription status // fetch and update the subscription status
isSubscribed = SubscriptionHelper.isSubscribed(channelId!!) isSubscribed = SubscriptionHelper.isSubscribed(channelId!!)
if (isSubscribed == null) return@launchWhenCreated if (isSubscribed == null) return@launchWhenCreated
runOnUiThread { runOnUiThread {
if (isSubscribed == true) { binding.channelSubscribe.setupSubscriptionButton(channelId, channelName, binding.notificationBell)
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.channelShare.setOnClickListener { 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) shareDialog.show(childFragmentManager, ShareDialog::class.java.name)
} }
} }
@ -165,23 +181,82 @@ class ChannelFragment : BaseFragment() {
ImageHelper.loadImage(response.avatarUrl, binding.channelImage) ImageHelper.loadImage(response.avatarUrl, binding.channelImage)
// recyclerview of the videos by the channel // recyclerview of the videos by the channel
channelAdapter = ChannelAdapter( channelAdapter = VideosAdapter(
response.relatedStreams!!.toMutableList(), response.relatedStreams.orEmpty().toMutableList(),
childFragmentManager forceMode = VideosAdapter.Companion.ForceMode.CHANNEL
) )
binding.channelRecView.adapter = channelAdapter binding.channelRecView.adapter = channelAdapter
} }
response.tabs?.let { setupTabs(it) }
}
}
private fun setupTabs(tabs: List<ChannelTab>) {
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() { private fun fetchChannelNextPage() {
fun run() { fun run() {
if (nextPage == null || isLoading) return
isLoading = true
binding.channelRefresh.isRefreshing = true
lifecycleScope.launchWhenCreated { lifecycleScope.launchWhenCreated {
val response = try { val response = try {
RetrofitInstance.api.getChannelNextPage(channelId!!, nextPage!!) RetrofitInstance.api.getChannelNextPage(channelId!!, nextPage!!)
} catch (e: IOException) { } catch (e: IOException) {
binding.channelRefresh.isRefreshing = false binding.channelRefresh.isRefreshing = false
println(e)
Log.e(TAG(), "IOException, you might not have internet connection") Log.e(TAG(), "IOException, you might not have internet connection")
return@launchWhenCreated return@launchWhenCreated
} catch (e: HttpException) { } catch (e: HttpException) {
@ -190,11 +265,33 @@ class ChannelFragment : BaseFragment() {
return@launchWhenCreated return@launchWhenCreated
} }
nextPage = response.nextpage nextPage = response.nextpage
channelAdapter?.updateItems(response.relatedStreams!!) channelAdapter?.insertItems(response.relatedStreams.orEmpty())
isLoading = false isLoading = false
binding.channelRefresh.isRefreshing = false binding.channelRefresh.isRefreshing = false
} }
} }
run() 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)
}
}
}
}
} }

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