diff --git a/_scripts/_localforage.js b/_scripts/_localforage.js index f0ee6d8239ec8..1e8fb0b46b81e 100644 --- a/_scripts/_localforage.js +++ b/_scripts/_localforage.js @@ -5,11 +5,26 @@ export function createInstance(kwargs) { const instance = localforage.createInstance(kwargs) return { async getItem(key) { + const dataLocationFile = android.readFile('data://', 'data-location.json') + if (dataLocationFile !== '') { + const locationInfo = JSON.parse(dataLocationFile) + const locationMap = Object.fromEntries(locationInfo.files.map((file) => { return [file.fileName, file.uri] })) + if (key in locationMap) { + return android.readFile(locationMap[key], '') + } + } const data = android.readFile("data://", key) - if (data === '') return instance.getItem(key) return data }, async setItem(key, value) { + const dataLocationFile = android.readFile('data://', 'data-location.json') + if (dataLocationFile !== '') { + const locationInfo = JSON.parse(dataLocationFile) + const locationMap = Object.fromEntries(locationInfo.files.map((file) => { return [file.fileName, file.uri] })) + if (key in locationMap) { + return android.writeFile(locationMap[key], '', value) + } + } android.writeFile("data://", key, value) } } diff --git a/android/app/src/main/java/io/freetubeapp/freetube/FreeTubeJavaScriptInterface.kt b/android/app/src/main/java/io/freetubeapp/freetube/FreeTubeJavaScriptInterface.kt index 1482149b818fd..fe37bbbee8c02 100644 --- a/android/app/src/main/java/io/freetubeapp/freetube/FreeTubeJavaScriptInterface.kt +++ b/android/app/src/main/java/io/freetubeapp/freetube/FreeTubeJavaScriptInterface.kt @@ -18,12 +18,14 @@ import android.webkit.JavascriptInterface import androidx.activity.result.ActivityResult import androidx.annotation.RequiresApi import androidx.core.app.NotificationManagerCompat +import androidx.documentfile.provider.DocumentFile import java.io.File import java.io.FileInputStream import java.net.URL import java.net.URLEncoder import java.util.UUID.* + class FreeTubeJavaScriptInterface { private var context: MainActivity private var mediaSession: MediaSession? @@ -453,6 +455,45 @@ class FreeTubeJavaScriptInterface { return promise } + @JavascriptInterface + fun requestDirectoryAccessDialog(): String { + val promise = jsPromise() + val openDialogIntent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + context.listenForActivityResults { + result: ActivityResult? -> + if (result!!.resultCode == Activity.RESULT_CANCELED) { + resolve(promise, "USER_CANCELED") + } + try { + val uri = result!!.data!!.data!! + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + resolve(promise, URLEncoder.encode(uri.toString(), "utf-8")) + } catch (ex: Exception) { + reject(promise, ex.toString()) + } + } + context.activityResultLauncher.launch(openDialogIntent) + return promise + } + + @JavascriptInterface + fun listFilesInTree(tree: String): String { + val directory = DocumentFile.fromTreeUri(context, Uri.parse(tree)) + val files = directory!!.listFiles().joinToString(",") { file -> + "{ \"uri\": \"${file.uri}\", \"fileName\": \"${file.name}\" }" + } + return "[$files]" + } + + @JavascriptInterface + fun createFileInTree(tree: String, fileName: String): String { + val directory = DocumentFile.fromTreeUri(context, Uri.parse(tree)) + return directory!!.createFile("*/*", fileName)!!.uri.toString() + } + /** * hides the splashscreen */ diff --git a/src/renderer/components/data-settings/data-settings.js b/src/renderer/components/data-settings/data-settings.js index edc7030920e4a..281edcc4714c2 100644 --- a/src/renderer/components/data-settings/data-settings.js +++ b/src/renderer/components/data-settings/data-settings.js @@ -21,7 +21,7 @@ import { } from '../../helpers/utils' import { invidiousAPICall } from '../../helpers/api/invidious' import { getLocalChannel } from '../../helpers/api/local' -import { handleAmbigiousContent } from '../../helpers/android' +import { handleAmbigiousContent, initalizeDatabasesInDirectory, readFile, requestDirectory, writeFile } from '../../helpers/android' export default defineComponent({ name: 'DataSettings', @@ -76,6 +76,9 @@ export default defineComponent({ }, primaryProfile: function () { return deepCopy(this.profileList[0]) + }, + usingAndroid: function () { + return process.env.IS_ANDROID } }, methods: { @@ -85,6 +88,60 @@ export default defineComponent({ }) }, + resetDataDirectory: async function () { + try { + const locationData = readFile('data://', 'data-location.json') + let locationInfo = { directory: 'data://', files: [] } + let locationMap = [] + if (locationData !== '') { + locationInfo = JSON.parse(locationData) + locationMap = locationInfo.files.map((file) => { return [file.fileName, file.uri] }) + } + if (locationMap.length !== 0) { + for (const [key, value] of locationMap) { + writeFile('data://', key, readFile(value)) + } + // clear out data-location.json + writeFile('data://', 'data-location.json', '') + showToast(this.$t('Data Settings.Your data directory has been moved successfully')) + } else { + showToast(this.$t('Data Settings.Nothing to change')) + } + } catch (exception) { + showToast(exception) + } + }, + + selectDataDirectory: async function () { + try { + const directory = await requestDirectory() + const files = await initalizeDatabasesInDirectory(directory) + if (files.length > 0) { + const locationData = readFile('data://', 'data-location.json') + let locationInfo = { directory: 'data://', files: [] } + let locationMap = {} + if (locationData !== '') { + locationInfo = JSON.parse(locationData) + locationMap = Object.fromEntries(locationInfo.files.map((file) => { return [file.fileName, file.uri] })) + } + for (let i = 0; i < files.length; i++) { + const data = locationInfo.files.length === 0 + ? readFile('data://', files[i].fileName) + : readFile(locationMap[files[i].fileName], '') + writeFile(files[i].uri, '', data) + } + // update the data files + writeFile('data://', 'data-location.json', JSON.stringify({ + directory: directory.uri, + files + })) + showToast(this.$t('Data Settings.Your data directory has been moved successfully')) + } + } catch (exception) { + showToast(this.$t('Data Settings.Error moving data directory')) + } + }, + importSubscriptions: async function () { const options = { properties: ['openFile'], diff --git a/src/renderer/components/data-settings/data-settings.vue b/src/renderer/components/data-settings/data-settings.vue index 456c6d3585c57..8fbcd7401ccf3 100644 --- a/src/renderer/components/data-settings/data-settings.vue +++ b/src/renderer/components/data-settings/data-settings.vue @@ -2,6 +2,21 @@ +

{{ $t('Subscriptions.Subscriptions') }}

diff --git a/src/renderer/components/ft-video-player/ft-video-player.js b/src/renderer/components/ft-video-player/ft-video-player.js index dcbab39d81ac4..3c0f0e563749c 100644 --- a/src/renderer/components/ft-video-player/ft-video-player.js +++ b/src/renderer/components/ft-video-player/ft-video-player.js @@ -713,7 +713,6 @@ export default defineComponent({ this.stopPowerSaveBlocker() }) - this.player.on(this.statsModalEventName, () => { if (this.showStatsModal) { this.statsModal.open() @@ -991,10 +990,11 @@ export default defineComponent({ this.player.one('canplay', () => { this.player.currentTime(currentTime) this.player.playbackRate(playbackRate) - - // need to call play to restore the player state, even if we want to pause afterwards - this.playVideo(() => { - if (isPaused) { this.player.pause() } + this.$refs.video.addEventListener('loadeddata', () => { + // need to call play to restore the player state, even if we want to pause afterwards + this.playVideo(() => { + if (isPaused) { this.player.pause() } + }) }) }) diff --git a/src/renderer/helpers/android.js b/src/renderer/helpers/android.js index 70fd585e43c03..b5033e8b30d23 100644 --- a/src/renderer/helpers/android.js +++ b/src/renderer/helpers/android.js @@ -192,3 +192,74 @@ export function handleAmbigiousContent(content, filePath) { } return filePath } + +/** + * @typedef AndroidFile + * @property {string} uri + * @property {string} fileName + */ + +/** + * @callback ListFiles + * @returns {Array} + */ + +/** + * @typedef DirectoryHandle + * @property {boolean} canceled + * @property {string?} uri + * @property {Function} createFile + * @property {ListFiles} listFiles + */ + +/** + * + * @returns {Promise} + */ +export async function requestDirectory() { + const uri = await window.awaitAsyncResult(android.requestDirectoryAccessDialog()) + if (uri === 'USER_CANCELED') { + return { + canceled: true + } + } else { + return { + uri, + canceled: false, + createFile(fileName) { + return android.createFileInTree(uri, fileName) + }, + listFiles() { + return JSON.parse(android.listFilesInTree(uri)) + } + } + } +} + +export const EXPECTED_FILES = ['profiles.db', 'settings.db', 'history.db', 'playlists.db'] + +/** + * + * @param {DirectoryHandle} directoryHandle + */ +export async function initalizeDatabasesInDirectory(directoryHandle) { + if (directoryHandle.canceled) { + return [] + } + const files = directoryHandle.listFiles() + const filteredFiles = files.filter(({ fileName }) => EXPECTED_FILES.indexOf(fileName) !== -1) + const filteredFileNames = filteredFiles.map((item) => item.fileName) + if (filteredFiles.length === EXPECTED_FILES.length) { + // no changes necessary + } else { + const neededFiles = EXPECTED_FILES.filter((fileName) => filteredFileNames.indexOf(fileName) === -1) + for (const file of neededFiles) { + const fileData = { + uri: directoryHandle.createFile(file), + fileName: file + } + filteredFiles.push(fileData) + } + } + return filteredFiles +} diff --git a/static/locales-android/en-US.yaml b/static/locales-android/en-US.yaml index 411db8cb3c9f9..fb1b6e74eeab7 100644 --- a/static/locales-android/en-US.yaml +++ b/static/locales-android/en-US.yaml @@ -4,3 +4,10 @@ Player Settings: Use Old Layout For Info: Use old layout for watch video info Distraction Free Settings: Hide Thumbnail In Media Controls: Hide thumbnail in media controls notification +Data Settings: + Data Directory: Data Directory + Select Data Directory: Select Data Directory + Reset Data Directory: Reset Data Directory + Your data directory has been moved successfully: Your data directory has been moved successfully + Error moving data directory: Error occured while moving data directory + Nothing to change: Nothing to change