FreeTube/src/renderer/helpers/utils.js

622 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import fs from 'fs/promises'
import { IpcChannels } from '../../constants'
import FtToastEvents from '../components/ft-toast/ft-toast-events'
import i18n from '../i18n/index'
import router from '../router/index'
// 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
}
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
}
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}`
}
// strip html tags but keep <br>, <b>, </b> <s>, </s>, <i>, </i>
export function stripHTML(value) {
return value.replaceAll(/(<(?!br|\/?[bis]>)([^>]+)>)/gi, '')
}
/**
* 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)
}