FreeTube/src/renderer/helpers/utils.js

622 lines
20 KiB
JavaScript
Raw Normal View History

import fs from 'fs/promises'
import { IpcChannels } from '../../constants'
2022-10-14 07:59:49 +02:00
import FtToastEvents from '../components/ft-toast/ft-toast-events'
import i18n from '../i18n/index'
import router from '../router/index'
2022-10-14 07:59:49 +02:00
// allowed characters in channel handle: A-Z, a-z, 0-9, -, _, .
// https://support.google.com/youtube/answer/11585688#change_handle
export const CHANNEL_HANDLE_REGEX = /^@[\w.-]{3,30}$/
export function calculatePublishedDate(publishedText) {
const date = new Date()
if (publishedText === 'Live') {
return publishedText
}
const textSplit = publishedText.split(' ')
if (textSplit[0].toLowerCase() === 'streamed') {
textSplit.shift()
}
const timeFrame = textSplit[1]
const timeAmount = parseInt(textSplit[0])
let timeSpan = null
if (timeFrame.indexOf('second') > -1) {
timeSpan = timeAmount * 1000
} else if (timeFrame.indexOf('minute') > -1) {
timeSpan = timeAmount * 60000
} else if (timeFrame.indexOf('hour') > -1) {
timeSpan = timeAmount * 3600000
} else if (timeFrame.indexOf('day') > -1) {
timeSpan = timeAmount * 86400000
} else if (timeFrame.indexOf('week') > -1) {
timeSpan = timeAmount * 604800000
} else if (timeFrame.indexOf('month') > -1) {
timeSpan = timeAmount * 2592000000
} else if (timeFrame.indexOf('year') > -1) {
timeSpan = timeAmount * 31556952000
}
return date.getTime() - timeSpan
}
export function toLocalePublicationString ({ publishText, isLive = false, isUpcoming = false, isRSS = false }) {
if (isLive) {
return '0' + i18n.t('Video.Watching')
} else if (isUpcoming || publishText === null) {
// the check for null is currently just an inferring of knowledge, because there is no other possibility left
return `${i18n.t('Video.Published.Upcoming')}: ${publishText}`
} else if (isRSS) {
return publishText
}
const strings = publishText.split(' ')
// filters out the streamed x hours ago and removes the streamed in order to keep the rest of the code working
if (strings[0].toLowerCase() === 'streamed') {
strings.shift()
}
const singular = (strings[0] === '1')
let translationKey = ''
switch (strings[1].substring(0, 2)) {
case 'se':
translationKey = 'Video.Published.Second'
break
case 'mi':
translationKey = 'Video.Published.Minute'
break
case 'ho':
translationKey = 'Video.Published.Hour'
break
case 'da':
translationKey = 'Video.Published.Day'
break
case 'we':
translationKey = 'Video.Published.Week'
break
case 'mo':
translationKey = 'Video.Published.Month'
break
case 'ye':
translationKey = 'Video.Published.Year'
break
default:
return publishText
}
if (!singular) {
translationKey += 's'
}
const unit = i18n.t(translationKey)
return i18n.t('Video.Publicationtemplate', { number: strings[0], unit })
}
export function buildVTTFileLocally(storyboard, videoLengthSeconds) {
let vttString = 'WEBVTT\n\n'
// how many images are in one image
const numberOfSubImagesPerImage = storyboard.columns * storyboard.rows
// the number of storyboard images
const numberOfImages = Math.ceil(storyboard.thumbnail_count / numberOfSubImagesPerImage)
let intervalInSeconds
if (storyboard.interval > 0) {
intervalInSeconds = storyboard.interval / 1000
} else {
intervalInSeconds = videoLengthSeconds / (numberOfImages * numberOfSubImagesPerImage)
}
let startHours = 0
let startMinutes = 0
let startSeconds = 0
let endHours = 0
let endMinutes = 0
let endSeconds = intervalInSeconds
for (let i = 0; i < numberOfImages; i++) {
const currentUrl = storyboard.template_url.replace('$M.jpg', `${i}.jpg`)
let xCoord = 0
let yCoord = 0
for (let j = 0; j < numberOfSubImagesPerImage; j++) {
// add the timestamp information
const paddedStartHours = startHours.toString().padStart(2, '0')
const paddedStartMinutes = startMinutes.toString().padStart(2, '0')
const paddedStartSeconds = startSeconds.toFixed(3).padStart(6, '0')
const paddedEndHours = endHours.toString().padStart(2, '0')
const paddedEndMinutes = endMinutes.toString().padStart(2, '0')
const paddedEndSeconds = endSeconds.toFixed(3).padStart(6, '0')
vttString += `${paddedStartHours}:${paddedStartMinutes}:${paddedStartSeconds} --> ${paddedEndHours}:${paddedEndMinutes}:${paddedEndSeconds}\n`
// add the current image url as well as the x, y, width, height information
vttString += `${currentUrl}#xywh=${xCoord},${yCoord},${storyboard.thumbnail_width},${storyboard.thumbnail_height}\n\n`
// update the variables
startHours = endHours
startMinutes = endMinutes
startSeconds = endSeconds
endSeconds += intervalInSeconds
if (endSeconds >= 60) {
endSeconds -= 60
endMinutes += 1
}
if (endMinutes >= 60) {
endMinutes -= 60
endHours += 1
}
// x coordinate can only be smaller than the width of one subimage * the number of subimages per row
xCoord = (xCoord + storyboard.thumbnail_width) % (storyboard.thumbnail_width * storyboard.columns)
// only if the x coordinate is , so in a new row, we have to update the y coordinate
if (xCoord === 0) {
yCoord += storyboard.thumbnail_height
}
}
}
return vttString
}
2022-10-14 07:59:49 +02:00
export async function getFormatsFromHLSManifest(manifestUrl) {
const response = await fetch(manifestUrl)
const text = await response.text()
const lines = text.split('\n').filter(line => line)
const formats = []
let currentHeight = 0
for (const line of lines) {
if (line.startsWith('#')) {
if (!line.startsWith('#EXT-X-STREAM-INF:')) {
continue
}
const height = line
.split(',')
.find(part => part.startsWith('RESOLUTION'))
.split('x')[1]
currentHeight = parseInt(height)
} else {
formats.push({
height: currentHeight,
url: line.trim()
})
}
}
return formats
}
2022-10-14 07:59:49 +02:00
export function showToast(message, time = null, action = null) {
FtToastEvents.$emit('toast-open', message, time, action)
}
/**
* This writes to the clipboard. If an error occurs during the copy,
* a toast with the error is shown. If the copy is successful and
* there is a success message, a toast with that message is shown.
* @param {string} content the content to be copied to the clipboard
* @param {string} messageOnSuccess the message to be displayed as a toast when the copy succeeds (optional)
* @param {string} messageOnError the message to be displayed as a toast when the copy fails (optional)
*/
export async function copyToClipboard(content, { messageOnSuccess = null, messageOnError = null }) {
if (navigator.clipboard !== undefined && window.isSecureContext) {
try {
await navigator.clipboard.writeText(content)
if (messageOnSuccess !== null) {
showToast(messageOnSuccess)
}
} catch (error) {
console.error(`Failed to copy ${content} to clipboard`, error)
if (messageOnError !== null) {
showToast(`${messageOnError}: ${error}`, 5000)
} else {
showToast(`${i18n.t('Clipboard.Copy failed')}: ${error}`, 5000)
}
}
} else {
showToast(i18n.t('Clipboard.Cannot access clipboard without a secure connection'), 5000)
}
}
/**
* Opens a link in the default web browser or a new tab in the web builds
* @param {string} url the URL to open
*/
export function openExternalLink(url) {
if (process.env.IS_ELECTRON) {
const ipcRenderer = require('electron').ipcRenderer
ipcRenderer.send(IpcChannels.OPEN_EXTERNAL_LINK, url)
} else {
window.open(url, '_blank')
}
}
/**
* Opens an internal path in the same or a new window.
* Optionally with query params and setting the contents of the search bar in the new window.
* @param {object} params
* @param {string} params.path the internal path to open
* @param {boolean} params.doCreateNewWindow set to true to open a new window
* @param {object} params.query the query params to use (optional)
* @param {string} params.searchQueryText the text to show in the search bar in the new window (optional)
*/
export function openInternalPath({ path, query = {}, doCreateNewWindow, searchQueryText = null }) {
if (process.env.IS_ELECTRON && doCreateNewWindow) {
const { ipcRenderer } = require('electron')
// Combine current document path and new "hash" as new window startup URL
const newWindowStartupURL = new URL(window.location.href)
newWindowStartupURL.hash = `${path}?${(new URLSearchParams(query)).toString()}`
ipcRenderer.send(IpcChannels.CREATE_NEW_WINDOW, {
windowStartupUrl: newWindowStartupURL.toString(),
searchQueryText
})
} else {
router.push({
path,
query
})
}
}
export async function showOpenDialog (options) {
if (process.env.IS_ELECTRON) {
const { ipcRenderer } = require('electron')
return await ipcRenderer.invoke(IpcChannels.SHOW_OPEN_DIALOG, options)
} else {
return await new Promise((resolve) => {
const fileInput = document.createElement('input')
fileInput.setAttribute('type', 'file')
if (options?.filters[0]?.extensions !== undefined) {
// this will map the given extensions from the options to the accept attribute of the input
fileInput.setAttribute('accept', options.filters[0].extensions.map((extension) => { return `.${extension}` }).join(', '))
}
fileInput.onchange = () => {
const files = Array.from(fileInput.files)
resolve({ canceled: false, files, filePaths: files.map(({ name }) => { return name }) })
delete fileInput.onchange
}
const listenForEnd = () => {
window.removeEventListener('focus', listenForEnd)
// 1 second timeout on the response from the file picker to prevent awaiting forever
setTimeout(() => {
if (fileInput.files.length === 0 && typeof fileInput.onchange === 'function') {
// if there are no files and the onchange has not been triggered, the file-picker was canceled
resolve({ canceled: true })
delete fileInput.onchange
}
}, 1000)
}
window.addEventListener('focus', listenForEnd)
fileInput.click()
})
}
}
/**
* @param {object} response the response from `showOpenDialog`
* @param {number} index which file to read (defaults to the first in the response)
* @returns the text contents of the selected file
*/
export function readFileFromDialog(response, index = 0) {
return new Promise((resolve, reject) => {
if (process.env.IS_ELECTRON) {
// if this is Electron, use fs
fs.readFile(response.filePaths[index])
.then(data => {
resolve(new TextDecoder('utf-8').decode(data))
})
.catch(reject)
} else {
// if this is web, use FileReader
try {
const reader = new FileReader()
reader.onload = function (file) {
resolve(file.currentTarget.result)
}
reader.readAsText(response.files[index])
} catch (exception) {
reject(exception)
}
}
})
}
export async function showSaveDialog (options) {
if (process.env.IS_ELECTRON) {
const { ipcRenderer } = require('electron')
return await ipcRenderer.invoke(IpcChannels.SHOW_SAVE_DIALOG, options)
} else {
// If the native filesystem api is available
if ('showSaveFilePicker' in window) {
return {
canceled: false,
handle: await window.showSaveFilePicker({
suggestedName: options.defaultPath.split('/').at(-1),
types: options.filters[0]?.extensions?.map((extension) => {
return {
accept: {
'application/octet-stream': '.' + extension
}
}
})
})
}
} else {
return { canceled: false, filePath: options.defaultPath }
}
}
}
/**
* Write to a file picked out from the `showSaveDialog` picker
* @param {object} response the response from `showSaveDialog`
* @param {string} content the content to be written to the file selected by the dialog
*/
export async function writeFileFromDialog (response, content) {
if (process.env.IS_ELECTRON) {
const { filePath } = response
return await fs.writeFile(filePath, content)
} else {
if ('showOpenFilePicker' in window) {
const { handle } = response
const writableStream = await handle.createWritable()
await writableStream.write(content)
await writableStream.close()
} else {
// If the native filesystem api is not available,
const { filePath } = response
const filename = filePath.split('/').at(-1)
const a = document.createElement('a')
const url = URL.createObjectURL(new Blob([content], { type: 'application/octet-stream' }))
a.setAttribute('href', url)
a.setAttribute('download', encodeURI(filename))
a.click()
}
}
}
/**
* This creates an absolute web url from a given path.
* It will assume all given paths are relative to the current window location.
* @param {string} path relative path to resource
* @returns {string} absolute web path
*/
export function createWebURL(path) {
const url = new URL(window.location.href)
const { origin } = url
let windowPath = url.pathname
// Remove the html file name from the path
if (windowPath.endsWith('.html')) {
windowPath = windowPath.replace(/[^./]*\.html$/, '')
}
// Remove proceeding slash in given path if there is one
if (path.startsWith('/')) {
path = path.substring(1, path.length)
}
// Remove trailing slash if there is one
if (windowPath.endsWith('/')) {
windowPath = windowPath.substring(0, windowPath.length - 1)
}
return `${origin}${windowPath}/${path}`
}
2022-11-01 15:14:09 +01:00
// strip html tags but keep <br>, <b>, </b> <s>, </s>, <i>, </i>
export function stripHTML(value) {
return value.replaceAll(/(<(?!br|\/?[bis]>)([^>]+)>)/gi, '')
2022-11-01 15:14:09 +01:00
}
/**
* This formats the duration of a video in seconds into a user friendly timestamp.
* It will return strings like LIVE or UPCOMING, without making any changes
* @param {string|number} lengthSeconds the video duration in seconds or the strings LIVE or UPCOMING
* @returns {string} timestamp or LIVE or UPCOMING
*/
export function formatDurationAsTimestamp(lengthSeconds) {
if (typeof lengthSeconds === 'string') {
return lengthSeconds
}
if (lengthSeconds === 0) {
return '0:00'
}
let hours = 0
if (lengthSeconds >= 3600) {
hours = Math.floor(lengthSeconds / 3600)
lengthSeconds = lengthSeconds - hours * 3600
}
let minutes = Math.floor(lengthSeconds / 60)
if (minutes < 10 && hours > 0) {
minutes = '0' + minutes
}
let seconds = lengthSeconds - minutes * 60
if (seconds < 10) {
seconds = '0' + seconds
}
let timestamp = ''
if (hours > 0) {
timestamp = hours + ':' + minutes + ':' + seconds
} else {
timestamp = minutes + ':' + seconds
}
return timestamp
}
export function searchFiltersMatch(filtersA, filtersB) {
return filtersA?.sortBy === filtersB?.sortBy &&
filtersA?.time === filtersB?.time &&
filtersA?.type === filtersB?.type &&
filtersA?.duration === filtersB?.duration
}
export function replaceFilenameForbiddenChars(filenameOriginal) {
let filenameNew = filenameOriginal
let forbiddenChars = {}
switch (process.platform) {
case 'win32':
forbiddenChars = {
'<': '', // U+FF1C
'>': '', // U+FF1E
':': '', // U+FF1A
'"': '', // U+FF02
'/': '', // U+FF0F
'\\': '', // U+FF3C
'|': '', // U+FF5C
'?': '', // U+FF1F
'*': '' // U+FF0A
}
break
case 'darwin':
forbiddenChars = { '/': '', ':': '' }
break
case 'linux':
forbiddenChars = { '/': '' }
break
default:
break
}
for (const forbiddenChar in forbiddenChars) {
filenameNew = filenameNew.replaceAll(forbiddenChar, forbiddenChars[forbiddenChar])
}
return filenameNew
}
export async function getSystemLocale() {
let locale
if (process.env.IS_ELECTRON) {
const { ipcRenderer } = require('electron')
locale = await ipcRenderer.invoke(IpcChannels.GET_SYSTEM_LOCALE)
} else {
if (navigator && navigator.language) {
locale = navigator.language
}
}
return locale || 'en-US'
}
export async function getUserDataPath() {
if (process.env.IS_ELECTRON) {
const { ipcRenderer } = require('electron')
return await ipcRenderer.invoke(IpcChannels.GET_USER_DATA_PATH)
} else {
// TODO: implement getUserDataPath web compatible callback
return null
}
}
export async function getPicturesPath() {
if (process.env.IS_ELECTRON) {
const { ipcRenderer } = require('electron')
return await ipcRenderer.invoke(IpcChannels.GET_PICTURES_PATH)
} else {
return null
}
}
export function extractNumberFromString(str) {
if (typeof str === 'string') {
return parseInt(str.replaceAll(/\D+/g, ''))
} else {
return NaN
}
}
export function showExternalPlayerUnsupportedActionToast(externalPlayer, actionName) {
const action = i18n.t(`Video.External Player.Unsupported Actions.${actionName}`)
const message = i18n.t('Video.External Player.UnsupportedActionTemplate', { externalPlayer, action })
showToast(message)
}
export function getVideoParamsFromUrl(url) {
/** @type {URL} */
let urlObject
const paramsObject = { videoId: null, timestamp: null, playlistId: null }
try {
urlObject = new URL(url)
} catch (e) {
return paramsObject
}
function extractParams(videoId) {
paramsObject.videoId = videoId
paramsObject.timestamp = urlObject.searchParams.get('t')
}
const extractors = [
// anything with /watch?v=
function () {
if (urlObject.pathname === '/watch' && urlObject.searchParams.has('v')) {
extractParams(urlObject.searchParams.get('v'))
paramsObject.playlistId = urlObject.searchParams.get('list')
return paramsObject
}
},
// youtu.be
function () {
if (urlObject.host === 'youtu.be' && /^\/[\w-]+$/.test(urlObject.pathname)) {
extractParams(urlObject.pathname.slice(1))
return paramsObject
}
},
// youtube.com/embed
function () {
if (/^\/embed\/[\w-]+$/.test(urlObject.pathname)) {
const urlTail = urlObject.pathname.replace('/embed/', '')
if (urlTail === 'videoseries') {
paramsObject.playlistId = urlObject.searchParams.get('list')
} else {
extractParams(urlTail)
}
return paramsObject
}
},
// youtube.com/shorts
function () {
if (/^\/shorts\/[\w-]+$/.test(urlObject.pathname)) {
extractParams(urlObject.pathname.replace('/shorts/', ''))
return paramsObject
}
},
// cloudtube
function () {
if (/^cadence\.(gq|moe)$/.test(urlObject.host) && /^\/cloudtube\/video\/[\w-]+$/.test(urlObject.pathname)) {
extractParams(urlObject.pathname.slice('/cloudtube/video/'.length))
return paramsObject
}
}
]
return extractors.reduce((a, c) => a || c(), null) || paramsObject
}
/**
* This will match sequences of upper case characters and convert them into title cased words.
* @param {string} title the title to process
* @param {number} minUpperCase the minimum number of consecutive upper case characters to match
* @returns {string} the title with upper case characters removed
*/
export function toDistractionFreeTitle(title, minUpperCase = 3) {
const firstValidCharIndex = (word) => {
const reg = /[\p{L}]/u
return word.search(reg)
}
const capitalizedWord = (word) => {
const chars = word.split('')
const index = firstValidCharIndex(word)
chars[index] = chars[index].toUpperCase()
return chars.join('')
}
const reg = RegExp(`[\\p{Lu}|']{${minUpperCase},}`, 'ug')
return title.replace(reg, x => capitalizedWord(x.toLowerCase()))
}
export function formatNumber(number, options = undefined) {
return Intl.NumberFormat([i18n.locale.replace('_', '-'), 'en'], options).format(number)
}