diff --git a/electron-builder.json5 b/electron-builder.json5 index c545728..b92f88f 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -20,7 +20,7 @@ }, { target: 'portable', - } + }, ], icon: 'public/icons/win/icon.ico', // artifactName: '${productName}-Windows-${version}-Setup.${ext}', @@ -32,8 +32,8 @@ deleteAppDataOnUninstall: true, }, mac: { - target: ['mas'], - artifactName: '${productName}-Mac-${version}-Installer.${ext}', + target: ['mas'], + artifactName: '${productName}-Mac-${version}-Installer.${ext}', }, linux: { target: ['AppImage', 'flatpak', 'tar.gz'], diff --git a/electron/main.ts b/electron/main.ts index 1c9209c..1e2a34a 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -175,9 +175,7 @@ ipcMain.on('delete-blob', (_, data) => { const extension = data.extension.includes('mp4') ? 'm4a' : 'opus' fs.promises .rm(path.join(downloadPath, `${data.id}.${extension}`)) - .catch((err) => { - console.log(err) - }) + .catch() .finally(sendDirSize) }) ipcMain.handle('get-blob', async (_, id) => { @@ -201,3 +199,9 @@ ipcMain.handle('get-blob', async (_, id) => { } } }) +ipcMain.handle('get-folder-path', () => { + return downloadPath +}) +ipcMain.handle('get-folder-content', async () => { + return await fs.promises.readdir(downloadPath) +}) diff --git a/forge.config.js b/forge.config.js deleted file mode 100644 index 955c4fd..0000000 --- a/forge.config.js +++ /dev/null @@ -1,30 +0,0 @@ -module.exports = { - packagerConfig: { - asar: true, - }, - rebuildConfig: {}, - makers: [ - { - name: '@electron-forge/maker-squirrel', - config: {}, - }, - { - name: '@electron-forge/maker-zip', - platforms: ['darwin'], - }, - { - name: '@electron-forge/maker-deb', - config: {}, - }, - { - name: '@electron-forge/maker-rpm', - config: {}, - }, - ], - plugins: [ - { - name: '@electron-forge/plugin-auto-unpack-natives', - config: {}, - }, - ], -} diff --git a/index.html b/index.html deleted file mode 100644 index 77c5194..0000000 --- a/index.html +++ /dev/null @@ -1,52 +0,0 @@ -<!doctype html> -<html> - <head> - <script type="module"> - import RefreshRuntime from '/@react-refresh' - RefreshRuntime.injectIntoGlobalHook(window) - window.$RefreshReg$ = () => {} - window.$RefreshSig$ = () => (type) => type - window.__vite_plugin_react_preamble_installed__ = true - </script> - - <script type="module" src="/@vite/client"></script> - - <meta charset="utf-8" /> - <!-- - Customize this policy to fit your own app's needs. For more guidance, please refer to the docs: - https://cordova.apache.org/docs/en/latest/ - Some notes: - * https://ssl.gstatic.com is required only on Android and is needed for TalkBack to function properly - * Disables use of inline scripts in order to mitigate risk of XSS vulnerabilities. To change this: - * Enable inline JS: add 'unsafe-inline' to default-src - --> - <meta - http-equiv="Content-Security-Policy" - content="default-src * 'self' 'unsafe-inline' 'unsafe-eval' data: content:" - /> - <meta - name="viewport" - content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no, viewport-fit=cover" - /> - - <meta name="theme-color" content="#212121" /> - <meta name="format-detection" content="telephone=no" /> - <meta name="msapplication-tap-highlight" content="no" /> - <title>Kiku</title> - - <meta name="apple-mobile-web-app-capable" content="yes" /> - <meta - name="apple-mobile-web-app-status-bar-style" - content="black-translucent" - /> - <link rel="apple-touch-icon" href="icons/apple-touch-icon.png" /> - <link rel="icon" href="icons/favicon.png" /> - - <!-- built styles file will be auto injected --> - </head> - <body> - <div id="app"></div> - - <script type="module" src="./js/app.js"></script> - </body> -</html> diff --git a/public/locales/en/common.json b/public/locales/en/common.json index f2a8708..ccd85a7 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -1,17 +1,17 @@ { - "Search-here": "Search here", - "Search": "Search", - "Now-Playing": "Now Playing", - "Search-Result": "Search Result", - "Setting": "Setting", - "Videos": "Videos", - "views": "views", - "Channel": "Channel", - "Playlists": "Playlists", - "Load-More": "Load More", - "Playlist": "Playlist", - "Search-Results": "Search Results", - "URL": "URL:", - "Global-UI": "Global UI", - "setting": "setting" + "Search-here": "Search here", + "Search": "Search", + "Now-Playing": "Now Playing", + "Search-Result": "Search Result", + "Setting": "Setting", + "Videos": "Videos", + "views": "views", + "Channel": "Channel", + "Playlists": "Playlists", + "Load-More": "Load More", + "Playlist": "Playlist", + "Search-Results": "Search Results", + "URL": "URL:", + "Global-UI": "Global UI", + "setting": "setting" } diff --git a/public/locales/en/common_old.json b/public/locales/en/common_old.json index d964bce..aa31794 100644 --- a/public/locales/en/common_old.json +++ b/public/locales/en/common_old.json @@ -1,6 +1,6 @@ { - "url": "url", - "details": { - "Relevant-Videos": "details.Relevant Videos" - } + "url": "url", + "details": { + "Relevant-Videos": "details.Relevant Videos" + } } diff --git a/public/locales/en/now-playing.json b/public/locales/en/now-playing.json index bc1fe04..482ab5d 100644 --- a/public/locales/en/now-playing.json +++ b/public/locales/en/now-playing.json @@ -1,4 +1,4 @@ { - "Nothing-is-playing": "Nothing is playing", - "Add-something-to-playlist-to-play": "Add something to playlist to play" + "Nothing-is-playing": "Nothing is playing", + "Add-something-to-playlist-to-play": "Add something to playlist to play" } diff --git a/public/locales/en/playlist.json b/public/locales/en/playlist.json index 18daee5..ab9972e 100644 --- a/public/locales/en/playlist.json +++ b/public/locales/en/playlist.json @@ -1,19 +1,22 @@ { - "Save-to-drive": "Save to drive", - "Remove-from-playlist": "Remove from playlist", - "Retry": "Retry", - "Are-you-sure-to-clear-the-playlist": "Are you sure to clear the playlist", - "Clear-playlist": "Clear playlist", - "Please-input-name-of-the-new-playlist": "Please input name of the new playlist", - "Playlist-Added": "Playlist Added", - "This-is-the-last-playlist": "This is the last playlist", - "Are-you-sure-to-remove-the-playlist?": "Are you sure to remove the playlist?", - "Please-input-the-new-name-for-playlist": "Please input the new name for playlist", - "Name-cannot-be-empty": "Name cannot be empty", - "New-Playlist": "New Playlist", - "Clear-Error-items": "Clear Error items", - "Clear-played-items": "Clear played items", - "Shuffle-unplayed-items": "Shuffle unplayed items", - "Remove-playlist": "Remove playlist", - "Rename-playlist": "Rename playlist" + "Save-to-drive": "Save to drive", + "Remove-from-playlist": "Remove from playlist", + "Retry": "Retry", + "Please-input-name-of-the-new-playlist": "Please input name of the new playlist", + "Playlist-Added": "Playlist Added", + "This-is-the-last-playlist": "This is the last playlist", + "Are-you-sure-to-remove-the-playlist?": "Are you sure to remove the playlist?", + "Please-input-the-new-name-for-playlist": "Please input the new name for playlist", + "Name-cannot-be-empty": "Name cannot be empty", + "New-Playlist": "New Playlist", + "Remove-playlist": "Remove playlist", + "Rename-playlist": "Rename playlist", + "Randomize-items": "Randomize items", + "Shuffle-unplayed-items": "Shuffle unplayed items", + "Shuffle-all-items": "Shuffle all items", + "Are-you-sure-to-clear-the-playlist": "Are you sure to clear the playlist", + "Clear-playlist": "Clear playlist", + "Remove-Items": "Remove Items", + "Clear-Error-items": "Clear Error items", + "Clear-played-items": "Clear played items" } diff --git a/public/locales/en/playlist_old.json b/public/locales/en/playlist_old.json index 7547a82..bfaf58f 100644 --- a/public/locales/en/playlist_old.json +++ b/public/locales/en/playlist_old.json @@ -1,3 +1,3 @@ { - "Download-to-drive": "Download to drive" + "Download-to-drive": "Download to drive" } diff --git a/public/locales/en/search-result.json b/public/locales/en/search-result.json index 5992a04..683ab18 100644 --- a/public/locales/en/search-result.json +++ b/public/locales/en/search-result.json @@ -1,15 +1,15 @@ { - "Details": "Details", - "Replace-Playlist-Prompt": "This will clear the current playlist. Are you sure to proceed?", - "Replace-current-playlist": "Replace current playlist", - "Browse-playlist": "Browse playlist", - "Add-all-to-playlist": "Add all to playlist", - "Already-on-playlist": "Already on playlist", - "Add-to-playlist": "Add to playlist", - "Add-to-next-song": "Add to next song", - "Copy-Link": "Copy Link", - "Open-link": "Open link", - "No-search-results": "No search results", - "Search-something-on-the-search-bar": "Search something on the search bar", - "Search-Results": "Search Results" + "Details": "Details", + "Replace-Playlist-Prompt": "This will clear the current playlist. Are you sure to proceed?", + "Replace-current-playlist": "Replace current playlist", + "Browse-playlist": "Browse playlist", + "Add-all-to-playlist": "Add all to playlist", + "Already-on-playlist": "Already on playlist", + "Add-to-playlist": "Add to playlist", + "Add-to-next-song": "Add to next song", + "Copy-Link": "Copy Link", + "Open-link": "Open link", + "No-search-results": "No search results", + "Search-something-on-the-search-bar": "Search something on the search bar", + "Search-Results": "Search Results" } diff --git a/public/locales/en/search-result_old.json b/public/locales/en/search-result_old.json index a2becfa..3e60d90 100644 --- a/public/locales/en/search-result_old.json +++ b/public/locales/en/search-result_old.json @@ -1,8 +1,8 @@ { - "Duration": "Duration", - "Published-at": "Published at", - "Views": "Views", - "Likes": "Likes", - "Description": "Description", - "Relevant-Videos": "Relevant Videos" + "Duration": "Duration", + "Published-at": "Published at", + "Views": "Views", + "Likes": "Likes", + "Description": "Description", + "Relevant-Videos": "Relevant Videos" } diff --git a/public/locales/en/setting.json b/public/locales/en/setting.json index 9291bf8..7dc8853 100644 --- a/public/locales/en/setting.json +++ b/public/locales/en/setting.json @@ -1,21 +1,23 @@ { - "Source": "Source", - "Youtube-Language": "Youtube Language", - "Youtube-Region": "Youtube Region", - "View-Invidious-Instances": "View Invidious Instances", - "View-Piped-Instances": "View Piped Instances", - "Reset-to-default": "Reset to default", - "Storage-size-must-be-a-positive-number": "Storage size must be a positive number", - "Store-audio-files-on-disk": "Store audio files on disk", - "Maximum-audio-files-storage": "Maximum audio files storage", - "Language": "Language", - "Theme": "Theme", - "Light": "Light", - "Dark": "Dark", - "Seek-Time": "Seek Time", - "Choose-Layout": "Choose Layout", - "Show-Timeline-in-wavesurfer": "Show Timeline in wavesurfer", - "Instance-Configuration": "Instance Configuration", - "UI-Preference": "UI Preference", - "Storage-Setting": "Storage Setting" + "Source": "Source", + "Youtube-Language": "Youtube Language", + "Youtube-Region": "Youtube Region", + "View-Invidious-Instances": "View Invidious Instances", + "View-Piped-Instances": "View Piped Instances", + "Reset-to-default": "Reset to default", + "Storage-size-must-be-a-positive-number": "Storage size must be a positive number", + "Store-audio-files-on-disk": "Store audio files on disk", + "Maximum-audio-files-storage": "Maximum audio files storage", + "Show-stored-files": "Show stored files", + "Manage-stored-files": "Manage stored files", + "Language": "Language", + "Theme": "Theme", + "Light": "Light", + "Dark": "Dark", + "Seek-Time": "Seek Time", + "Choose-Layout": "Choose Layout", + "Show-Timeline-in-wavesurfer": "Show Timeline in wavesurfer", + "Instance-Configuration": "Instance Configuration", + "UI-Preference": "UI Preference", + "Storage-Setting": "Storage Setting" } diff --git a/public/locales/en/setting_old.json b/public/locales/en/setting_old.json index 8b10cac..704a02e 100644 --- a/public/locales/en/setting_old.json +++ b/public/locales/en/setting_old.json @@ -1,6 +1,6 @@ { - "Youtube-Country": "Youtube Country", - "Enable-Blob-Storage": "Enable Blob Storage", - "Maximum-Blob-Storage": "Maximum Blob Storage", - "Blob-Size-must-be-a-positive-number": "Blob Size must be a positive number" + "Youtube-Country": "Youtube Country", + "Enable-Blob-Storage": "Enable Blob Storage", + "Maximum-Blob-Storage": "Maximum Blob Storage", + "Blob-Size-must-be-a-positive-number": "Blob Size must be a positive number" } diff --git a/public/locales/en/storage.json b/public/locales/en/storage.json new file mode 100644 index 0000000..9781a90 --- /dev/null +++ b/public/locales/en/storage.json @@ -0,0 +1,22 @@ +{ + "This-will-remove-all-files-in-the-download-folder-Are-you-sure-to-proceed?": "This will remove all files in the download folder Are you sure to proceed?", + "Removed-files-without-entries": "Removed files without entries", + "Removed-entries-without-files": "Removed entries without files", + "Removed-selected-files": "Removed selected files", + "Added-{{count}}-items-to-playlist_one": "Added {{count}} items to playlist", + "Added-{{count}}-items-to-playlist_other": "Added {{count}} items to playlist", + "Unknown": "Unknown", + "Storage-management": "Storage management", + "items-selected": "items selected", + "Add-entries-to-current-playlist": "Add entries to current playlist", + "Remove-selected-entries": "Remove selected entries", + "Audio-files": "Audio files", + "Remove-non-existent-entries": "Remove non existent entries", + "Remove-unhandled-entries": "Remove unhandled entries", + "Remove-all-files": "Remove all files", + "Title": "Title", + "Video-Id": "Video Id", + "Created-on": "Created on", + "Last-access": "Last access", + "File-exist": "File exist" +} diff --git a/public/locales/en/video-detail.json b/public/locales/en/video-detail.json index a2becfa..2ccea9c 100644 --- a/public/locales/en/video-detail.json +++ b/public/locales/en/video-detail.json @@ -1,8 +1,9 @@ { - "Duration": "Duration", - "Published-at": "Published at", - "Views": "Views", - "Likes": "Likes", - "Description": "Description", - "Relevant-Videos": "Relevant Videos" + "Copied-Link": "Copied Link", + "Duration": "Duration", + "Published-at": "Published at", + "Views": "Views", + "Likes": "Likes", + "Description": "Description", + "Relevant-Videos": "Relevant Videos" } diff --git a/public/locales/zh-TW/common.json b/public/locales/zh-TW/common.json index 8e54ae7..5802b84 100644 --- a/public/locales/zh-TW/common.json +++ b/public/locales/zh-TW/common.json @@ -1,17 +1,17 @@ { - "Search-here": "在此搜尋", - "Search": "搜尋", - "Now-Playing": "現正播放", - "Search-Result": "搜尋結果", - "Setting": "設定", - "Videos": "影片", - "views": "觀看次數", - "Channel": "頻道", - "Playlists": "播放清單", - "Load-More": "載入更多", - "Playlist": "播放清單", - "Search-Results": "", - "URL": "鏈結:", - "Global-UI": "介面", - "setting": "設定" + "Search-here": "在此搜尋", + "Search": "搜尋", + "Now-Playing": "現正播放", + "Search-Result": "搜尋結果", + "Setting": "設定", + "Videos": "影片", + "views": "觀看次數", + "Channel": "頻道", + "Playlists": "播放清單", + "Load-More": "載入更多", + "Playlist": "播放清單", + "Search-Results": "", + "URL": "鏈結:", + "Global-UI": "介面", + "setting": "設定" } diff --git a/public/locales/zh-TW/now-playing.json b/public/locales/zh-TW/now-playing.json index f5b5b73..a57edd0 100644 --- a/public/locales/zh-TW/now-playing.json +++ b/public/locales/zh-TW/now-playing.json @@ -1,4 +1,4 @@ { - "Nothing-is-playing": "尚未有播放項目", - "Add-something-to-playlist-to-play": "請在搜尋中添加播放項目" + "Nothing-is-playing": "尚未有播放項目", + "Add-something-to-playlist-to-play": "請在搜尋中添加播放項目" } diff --git a/public/locales/zh-TW/playlist.json b/public/locales/zh-TW/playlist.json index c1cbbd2..b35c96b 100644 --- a/public/locales/zh-TW/playlist.json +++ b/public/locales/zh-TW/playlist.json @@ -1,19 +1,22 @@ { - "Save-to-drive": "儲存", - "Remove-from-playlist": "從播放清單移除", - "Retry": "重試", - "Are-you-sure-to-clear-the-playlist": "你確定要清空播放清單嗎?", - "Clear-playlist": "清空播放清單", - "Please-input-name-of-the-new-playlist": "", - "Playlist-Added": "", - "This-is-the-last-playlist": "", - "Are-you-sure-to-remove-the-playlist?": "", - "Please-input-the-new-name-for-playlist": "", - "Name-cannot-be-empty": "", - "New-Playlist": "", - "Clear-Error-items": "移除錯誤項目", - "Clear-played-items": "移除已播放項目", - "Shuffle-unplayed-items": "隨機打亂未播放項目", - "Remove-playlist": "", - "Rename-playlist": "" + "Save-to-drive": "儲存", + "Remove-from-playlist": "從播放清單移除", + "Retry": "重試", + "Please-input-name-of-the-new-playlist": "請輸入新播放清單的名字", + "Playlist-Added": "已新增播放清單", + "This-is-the-last-playlist": "這是最後一條播放清單", + "Are-you-sure-to-remove-the-playlist?": "你確定要移除播放清單嗎", + "Please-input-the-new-name-for-playlist": "請輸入播放清單的新名字", + "Name-cannot-be-empty": "名字不能為空白", + "New-Playlist": "新增播放清單", + "Remove-playlist": "移除播放清單", + "Rename-playlist": "重新命名播放清單", + "Randomize-items": "隨機打亂", + "Shuffle-unplayed-items": "隨機打亂未播放項目", + "Shuffle-all-items": "隨機打亂所有項目", + "Are-you-sure-to-clear-the-playlist": "你確定要清空播放清單嗎?", + "Clear-playlist": "清空播放清單", + "Remove-Items": "移除項目", + "Clear-Error-items": "移除錯誤項目", + "Clear-played-items": "移除已播放項目" } diff --git a/public/locales/zh-TW/search-result.json b/public/locales/zh-TW/search-result.json index 5322d34..631c7e9 100644 --- a/public/locales/zh-TW/search-result.json +++ b/public/locales/zh-TW/search-result.json @@ -1,15 +1,15 @@ { - "Details": "影片詳情", - "Replace-Playlist-Prompt": "這會清空現時的播放清單。你確定要取代現時播放清單嗎?", - "Replace-current-playlist": "取代現時播放清單", - "Browse-playlist": "瀏覽清單", - "Add-all-to-playlist": "添加所有項目至播放清單", - "Already-on-playlist": "已經在播放清單", - "Add-to-playlist": "加到播放清單", - "Add-to-next-song": "加至下一首", - "Copy-Link": "複製鏈結", - "Open-link": "開啟鏈結", - "No-search-results": "尚未有搜尋結果", - "Search-something-on-the-search-bar": "請使用上方的搜尋欄", - "Search-Results": "搜尋結果:" + "Details": "影片詳情", + "Replace-Playlist-Prompt": "這會清空現時的播放清單。你確定要取代現時播放清單嗎?", + "Replace-current-playlist": "取代現時播放清單", + "Browse-playlist": "瀏覽清單", + "Add-all-to-playlist": "添加所有項目至播放清單", + "Already-on-playlist": "已經在播放清單", + "Add-to-playlist": "加到播放清單", + "Add-to-next-song": "加至下一首", + "Copy-Link": "複製鏈結", + "Open-link": "開啟鏈結", + "No-search-results": "尚未有搜尋結果", + "Search-something-on-the-search-bar": "請使用上方的搜尋欄", + "Search-Results": "搜尋結果:" } diff --git a/public/locales/zh-TW/setting.json b/public/locales/zh-TW/setting.json index 1f9baeb..6254df1 100644 --- a/public/locales/zh-TW/setting.json +++ b/public/locales/zh-TW/setting.json @@ -1,21 +1,23 @@ { - "Source": "來源", - "Youtube-Language": "Youtube 語言", - "Youtube-Region": "Youtube 地區", - "View-Invidious-Instances": "顯示 Invidious 站台", - "View-Piped-Instances": "顯示 Piped 站台", - "Reset-to-default": "重設至預設", - "Storage-size-must-be-a-positive-number": "儲存空間必須是正數", - "Store-audio-files-on-disk": "在裝置上儲存音訊檔", - "Maximum-audio-files-storage": "最大音訊檔佔用空間", - "Language": "語言", - "Theme": "主題", - "Light": "亮色", - "Dark": "暗色", - "Seek-Time": "轉跳時間", - "Choose-Layout": "選擇版面", - "Show-Timeline-in-wavesurfer": "在wavesurfer 中顯示時間列", - "Instance-Configuration": "站台設定", - "UI-Preference": "介面偏好", - "Storage-Setting": "儲存設定" + "Source": "來源", + "Youtube-Language": "Youtube 語言", + "Youtube-Region": "Youtube 地區", + "View-Invidious-Instances": "顯示 Invidious 站台", + "View-Piped-Instances": "顯示 Piped 站台", + "Reset-to-default": "重設至預設", + "Storage-size-must-be-a-positive-number": "儲存空間必須是正數", + "Store-audio-files-on-disk": "在裝置上儲存音訊檔", + "Maximum-audio-files-storage": "最大音訊檔佔用空間", + "Show-stored-files": "顯示已儲存的檔案", + "Manage-stored-files": "管理已儲存的檔案", + "Language": "語言", + "Theme": "主題", + "Light": "亮色", + "Dark": "暗色", + "Seek-Time": "轉跳時間", + "Choose-Layout": "選擇版面", + "Show-Timeline-in-wavesurfer": "在wavesurfer 中顯示時間列", + "Instance-Configuration": "站台設定", + "UI-Preference": "介面偏好", + "Storage-Setting": "儲存設定" } diff --git a/public/locales/zh-TW/setting_old.json b/public/locales/zh-TW/setting_old.json index a370308..c68c9aa 100644 --- a/public/locales/zh-TW/setting_old.json +++ b/public/locales/zh-TW/setting_old.json @@ -1,6 +1,6 @@ { - "Youtube-Country": "", - "Enable-Blob-Storage": "啟用儲存音訊檔", - "Maximum-Blob-Storage": "最大音訊儲存量", - "Blob-Size-must-be-a-positive-number": "" + "Youtube-Country": "", + "Enable-Blob-Storage": "啟用儲存音訊檔", + "Maximum-Blob-Storage": "最大音訊儲存量", + "Blob-Size-must-be-a-positive-number": "" } diff --git a/public/locales/zh-TW/storage.json b/public/locales/zh-TW/storage.json new file mode 100644 index 0000000..d80df38 --- /dev/null +++ b/public/locales/zh-TW/storage.json @@ -0,0 +1,21 @@ +{ + "This-will-remove-all-files-in-the-download-folder-Are-you-sure-to-proceed?": "你確定要移除所有在下載資料夾中的檔案嗎?", + "Removed-files-without-entries": "已移除所有未在記錄中的檔案", + "Removed-entries-without-files": "已移除所有沒有檔案的記錄", + "Removed-selected-files": "已移除選取的項目", + "Added-{{count}}-items-to-playlist_other": "已新增{{count}}個項目到播放列表", + "Unknown": "未知", + "Storage-management": "管理已儲存的資料", + "items-selected": "個已選擇的項目", + "Add-entries-to-current-playlist": "新增已選取的項目到播放清單", + "Remove-selected-entries": "移除已選取的項目", + "Audio-files": "音訊檔案", + "Remove-non-existent-entries": "移除所有沒有檔案的記錄", + "Remove-unhandled-entries": "移除所有未在記錄中的檔案", + "Remove-all-files": "移除所有項目", + "Title": "標題", + "Video-Id": "影片編號", + "Created-on": "建立時間", + "Last-access": "最後存取時間", + "File-exist": "檔案是否存在" +} diff --git a/public/locales/zh-TW/video-detail.json b/public/locales/zh-TW/video-detail.json index ed8ce1a..c4a3946 100644 --- a/public/locales/zh-TW/video-detail.json +++ b/public/locales/zh-TW/video-detail.json @@ -1,8 +1,9 @@ { - "Duration": "影片長度", - "Published-at": "上載日期", - "Views": "觀看次數", - "Likes": "喜歡次數", - "Description": "介紹", - "Relevant-Videos": "相關影片" + "Copied-Link": "已複制鏈結", + "Duration": "影片長度", + "Published-at": "上載日期", + "Views": "觀看次數", + "Likes": "喜歡次數", + "Description": "介紹", + "Relevant-Videos": "相關影片" } diff --git a/src/components/AudioWatcher.tsx b/src/components/AudioWatcher.tsx index 57f799f..f183cd4 100644 --- a/src/components/AudioWatcher.tsx +++ b/src/components/AudioWatcher.tsx @@ -70,6 +70,8 @@ export default function AudioWatcher(): ReactElement { playingItem != undefined && playingItem?.id != playerState.currentPlaying?.id ) { + audio.current.pause() + audio.current.currentTime = 0 dispatch(setSong(playingItem)) const newAudio = new Audio() newAudio.src = URL.createObjectURL(getBlobByID(playingItem.id)) diff --git a/src/components/ChannelResultCard.tsx b/src/components/ChannelResultCard.tsx index 9b0395a..6a96c7b 100644 --- a/src/components/ChannelResultCard.tsx +++ b/src/components/ChannelResultCard.tsx @@ -28,7 +28,7 @@ export default function ChannelResultCard(props: ChannelResultCardProps) { {/* Overlays */} <a className="absolute w-full h-full hidden group-hover:flex bg-black/60 backdrop-blur-sm flex-wrap justify-center items-center" - href={`channel/${props.data.authorId}`} + href={`/channel/${props.data.authorId}`} > <Icon className="text-lg lg:text-2xl xl:text-4xl w-full" @@ -46,7 +46,7 @@ export default function ChannelResultCard(props: ChannelResultCardProps) { </div> {/* ChannelTitle Title Here */} <Link - href={`channel/${props.data.authorId}`} + href={`/channel/${props.data.authorId}`} className="mt-2 line-clamp-2 underline" > {props.data.author} diff --git a/src/components/MainNav.tsx b/src/components/MainNav.tsx index f658fd8..5311488 100644 --- a/src/components/MainNav.tsx +++ b/src/components/MainNav.tsx @@ -10,9 +10,7 @@ import { } from 'framework7-react' import { handleSuggest } from '../js/suggestions' -import { handleSearchVideo } from '../js/search' import { useDispatch, useSelector } from 'react-redux' -import { newSearch, selectSearch } from '@/store/searchReducers' import { Store, useCustomContext } from '@/store/reactContext' import Innertube from 'youtubei.js/agnostic' import { selectConfig } from '@/store/globalConfig' @@ -44,8 +42,6 @@ const MainNav = (props: MainNavProps) => { const onPageBeforeRemove = () => { autocompleteSearch.current.destroy() } - const search = useSelector(selectSearch) - const { setContinuation } = useCustomContext(Store) const dispatch = useDispatch() const { t } = useTranslation(['common']) @@ -91,18 +87,25 @@ const MainNav = (props: MainNavProps) => { // Try to scan for url on input try { const url = new URL(searchTerm) // Will jump to catch if fail + f7.preloader.showIn('#page-router') let id: string | null let playlistId: string | null + let channelId: string | null // Extract parameters and path from url to get video id if (url.hostname === 'youtu.be') { id = url.pathname.replaceAll('/', '') playlistId = null + channelId = null } else { id = url.searchParams.get('v') playlistId = url.searchParams.get('list') + channelId = url.pathname.includes('channel/') + ? url.pathname.replace(/^\/channel\//, '') + : null } + console.log(id) // Skip if fail to get video id - if (id === null && playlistId === null) { + if (id === null && playlistId === null && channelId === null) { throw new Error('') } // Browse playlist if playlist is found @@ -111,7 +114,17 @@ const MainNav = (props: MainNavProps) => { f7.views .get('#page-router') .router.navigate(`playlist/${playlistId}`) + return } + // Browse channel if channel id is found + if (channelId !== null) { + fullfilled = true + f7.views + .get('#page-router') + .router.navigate(`channel/${channelId}`) + return + } + // Fetch basic information on given video id const res = await getPlayitem( id as string, diff --git a/src/components/Toast.ts b/src/components/Toast.ts index 0f1ebd3..e2b404f 100644 --- a/src/components/Toast.ts +++ b/src/components/Toast.ts @@ -5,14 +5,15 @@ const presentToast = (type: ToastType, message: string) => { type === 'info' ? '<i class="f7-icons">info_circle</i>' : type === 'error' - ? '<i class="f7-icons">exclamationmark_circle</i>' + ? '<i class="f7-icons">exclamationmark_circle_fill</i>' : type === 'success' ? '<i class="f7-icons">checkmark_alt_circle</i>' : '' const newToast = f7.toast.create({ icon: icon, text: message, - position: 'center', + horizontalPosition: 'center', + position: 'bottom', closeTimeout: 2000, }) newToast.open() diff --git a/src/components/VideoResultCard.tsx b/src/components/VideoResultCard.tsx index e1642f2..8f3da65 100644 --- a/src/components/VideoResultCard.tsx +++ b/src/components/VideoResultCard.tsx @@ -1,7 +1,7 @@ import { Icon, Link } from 'framework7-react' import React, { useState } from 'react' import { VideoResult, Playitem } from '@/typescript/interfaces' -import { formatViewNumber, convertSecond } from '../utils/format' +import { convertSecond, compactNumber } from '../utils/format' import { useDispatch, useSelector } from 'react-redux' import { addToNextSong, @@ -157,7 +157,7 @@ export default function VideoResultCard(props: VideoResultCardProps) { </Link> {props.data.viewCount !== undefined && ( <p> - {formatViewNumber(props.data.viewCount)}{' '} + {compactNumber(props.data.viewCount)}{' '} {t('common:views')} </p> )} diff --git a/src/components/Worker.tsx b/src/components/Worker.tsx index e60c5d2..8cd1dc2 100644 --- a/src/components/Worker.tsx +++ b/src/components/Worker.tsx @@ -24,11 +24,18 @@ import { Store } from '@/store/reactContext' import { selectPlayer } from '@/store/playerReducers' import { selectConfig } from '@/store/globalConfig' import Innertube from 'youtubei.js/agnostic' -import presentToast from './Toast' import { getNextSong } from '@/utils/songControl' import { base64ToBlob, blobToBase64 } from '@/utils/base64' -import { deleteBlob, saveBlob, selectLocalBlobs } from '@/store/blobStorage' -import {savePlaylist, selectLocalPlaylist} from '@/store/localPlaylistReducers' +import { + deleteBlob, + saveBlob, + selectLocalBlobs, + updateAccess, +} from '@/store/blobStorage' +import { + savePlaylist, + selectLocalPlaylist, +} from '@/store/localPlaylistReducers' // eslint-disable-next-line @typescript-eslint/no-var-requires const { ipcRenderer } = require('electron') @@ -45,6 +52,8 @@ export default function Worker(): ReactElement { const dispatch = useDispatch() // Spawn different instance of local forage for corresponding purpose const localBlobsRef = useRef(localBlobs) + // Will be updated when config is changed, used for ipcRenderer to keep the function updated with config + const configRef = useRef(config) // Get variables from react context const { dispatchAudioBlob, @@ -61,7 +70,6 @@ export default function Worker(): ReactElement { const [queue, setQueue] = useState<Playitem[]>([]) const [workerState, setWorkerState] = useState<string>('idle') - // Helper function of adding abort controller to react context const handleAddAbortController = ( id: string, @@ -83,59 +91,65 @@ export default function Worker(): ReactElement { innertube?.current, axiosController ) - // Pass parameters to fetch stream, including an abort controller to stop downloading if the item is removed - .then((blob: Blob | Error) => { - // Check if the result is an error - if (blob instanceof Error) { - throw new Error(blob as unknown as string) - } else { - // Dispatch the audio blob to react context - dispatchAudioBlob({ - type: 'ADD_BLOB', - payload: { id: nextJob.id, blob: blob }, - }) - if (config.storage.enalbeBlobStorage) { - // Store to local disk if enabled - blobToBase64(blob).then((base64) => { - // Send data as base64 to ipcMain - ipcRenderer.send('create-blob', { - id: nextJob.id, - blob: base64, - extension: blob.type, - }) - const timeNow = new Date().getTime() - // Make a record of the blob for deletion later - dispatch( - saveBlob({ + // Pass parameters to fetch stream, including an abort controller to stop downloading if the item is removed + .then((blob: Blob | Error) => { + // Check if the result is an error + if (blob instanceof Error) { + throw new Error(blob as unknown as string) + } else { + // Dispatch the audio blob to react context + dispatchAudioBlob({ + type: 'ADD_BLOB', + payload: { id: nextJob.id, blob: blob }, + }) + if (configRef.current.storage.enalbeBlobStorage) { + // Store to local disk if enabled + blobToBase64(blob).then((base64) => { + // Send data as base64 to ipcMain + ipcRenderer.send('create-blob', { id: nextJob.id, - title: nextJob.title, + blob: base64, extension: blob.type, - created: timeNow, - lastAccess: timeNow, }) - ) - }) + const timeNow = new Date().getTime() + // Make a record of the blob for deletion later + if ( + localBlobs.find( + (blob) => blob.id === nextJob.id + ) === undefined + ) { + dispatch( + saveBlob({ + id: nextJob.id, + title: nextJob.title, + extension: blob.type, + created: timeNow, + lastAccess: timeNow, + }) + ) + } + }) + } + setWorkerState('idle') + dispatch( + setItemDownloadStatus({ + id: nextJob.id, + status: 'downloaded', + }) + ) } + }) + .catch((err: Error) => { setWorkerState('idle') + console.log(err) + // Show toast dispatch( setItemDownloadStatus({ id: nextJob.id, - status: 'downloaded', + status: 'error', }) ) - } - }) - .catch((err: Error) => { - setWorkerState('idle') - console.log(err) - // Show toast - dispatch( - setItemDownloadStatus({ - id: nextJob.id, - status: 'error', - }) - ) - }) + }) } // Get the next job from playlist @@ -178,18 +192,23 @@ export default function Worker(): ReactElement { } const generateQueue = () => { + // Helper function for generating new queue for worker let newQueue: Playitem[] = [] - const currentPlayingIndex = playlist.findIndex(item => item.status === 'playing') + const currentPlayingIndex = playlist.findIndex( + (item) => item.status === 'playing' + ) if (currentPlayingIndex === -1) { - newQueue = playlist.filter(item => item.downloadStatus !== 'downloaded') + newQueue = playlist.filter( + (item) => item.downloadStatus !== 'downloaded' + ) } else { - for (let i = currentPlayingIndex + 1; i < playlist.length; i++){ - if (playlist[i].downloadStatus !== 'downloaded'){ + for (let i = currentPlayingIndex + 1; i < playlist.length; i++) { + if (playlist[i].downloadStatus !== 'downloaded') { newQueue.push(playlist[i]) } } - for (let i = 0; i < currentPlayingIndex; i++){ - if (playlist[i].downloadStatus !== 'downloaded'){ + for (let i = 0; i < currentPlayingIndex; i++) { + if (playlist[i].downloadStatus !== 'downloaded') { newQueue.push(playlist[i]) } } @@ -198,7 +217,8 @@ export default function Worker(): ReactElement { } const queueChanged = (newQueue: Playitem[]) => { - if (newQueue.length !== queue.length){ + // Helper function for checking if the queue has changed, only checking the id of every item + if (newQueue.length !== queue.length) { return true } else { return !newQueue.every((item, index) => item.id === queue[index].id) @@ -206,23 +226,26 @@ export default function Worker(): ReactElement { } useEffect(() => { + // Watch for playlist const newQueue = generateQueue() - if (queueChanged(newQueue)){ - setQueue(newQueue) + if (queueChanged(newQueue)) { + setQueue(newQueue) // Only trigger the worker when there is a change in queue } }, [playlist]) useEffect(() => { // console.log(workerState) // Only work when the status is idle and next job exists - if (workerState !== 'idle' || queue.length === 0){ - return + if (workerState !== 'idle' || queue.length === 0) { + return } - setWorkerState('working'); + setWorkerState('working') const nextJob = queue[0] - console.log(`[Worker] Start download video: ${nextJob.id} - ${nextJob.title}`) + console.log( + `[Worker] Start download video: ${nextJob.id} - ${nextJob.title}` + ) // Create an abort controller for axios - const axiosController = new AbortController(); + const axiosController = new AbortController() // Add the abort controller to react context handleAddAbortController(nextJob.id, axiosController) // Tell redux that the current job is downloading @@ -237,41 +260,42 @@ export default function Worker(): ReactElement { ) // Try to find currently existing local blob first if (matchingLocalBlob !== undefined) { // If localBlob can be found, try to invoke a request to main process through ipcRenderer - ipcRenderer.invoke('get-blob', nextJob.id) - .then((res: {exist: boolean, data: undefined | string}) =>{ // ipcMain will return the data in base64 format - if (res.exist && res.data !== undefined){ - return base64ToBlob(res.data) // Convert back to blob if retrieved successfully - } else { - throw new Error('Failed to fetch from local storage') - } - }) - .then((blob: Blob) => { - // Add the blob the blob store afterwards - dispatchAudioBlob({ - type: 'ADD_BLOB', - payload: { - id: nextJob.id, - blob: blob, - }, + ipcRenderer + .invoke('get-blob', nextJob.id) + .then((res: { exist: boolean; data: undefined | string }) => { + // ipcMain will return the data in base64 format + if (res.exist && res.data !== undefined) { + return base64ToBlob(res.data) // Convert back to blob if retrieved successfully + } else { + throw new Error('Failed to fetch from local storage') + } }) - dispatch( - setItemDownloadStatus({ - id: nextJob.id, - status: 'downloaded', + .then((blob: Blob) => { + // Add the blob the blob store afterwards + dispatchAudioBlob({ + type: 'ADD_BLOB', + payload: { + id: nextJob.id, + blob: blob, + }, }) - ) - setWorkerState('idle') - }) - .catch(()=>{ - fetchStream(nextJob, axiosController) - }) + dispatch( + setItemDownloadStatus({ + id: nextJob.id, + status: 'downloaded', + }) + ) + dispatch(updateAccess(nextJob.id)) + setWorkerState('idle') + }) + .catch(() => { + fetchStream(nextJob, axiosController) + }) } else { fetchStream(nextJob, axiosController) } }, [queue, workerState]) - - // const getIsDownloading = () => { // return playlist.some((item) => item.downloadStatus === 'downloading') // } @@ -395,6 +419,7 @@ export default function Worker(): ReactElement { } as LocalBlobEntry ) dispatch(deleteBlob(targetBlob.id)) // Remove item from local blob entry + console.log('delete-blob', targetBlob) ipcRenderer.send('delete-blob', { id: targetBlob.id, extension: targetBlob.extension, @@ -405,34 +430,43 @@ export default function Worker(): ReactElement { // Load playlist on startup or changing useEffect(() => { - const currentPlaylist = localPlaylists.playlists.find(playlist => playlist.id === localPlaylists.currentPlaylistId) as LocalPlaylist + const currentPlaylist = localPlaylists.playlists.find( + (playlist) => playlist.id === localPlaylists.currentPlaylistId + ) as LocalPlaylist dispatch(loadPlaylist(currentPlaylist.data)) }, [localPlaylists.currentPlaylistId]) // Watch for current playlist, dispatch to save playlist if changed detected useEffect(() => { let changed = false - const newPlaylist: Playitem[] = playlist.map(item => { + const newPlaylist: Playitem[] = playlist.map((item) => { return { ...item, downloadStatus: 'pending', - status: 'added' + status: 'added', } }) - const currentPlaylist = localPlaylists.playlists.find(item => item.id === localPlaylists.currentPlaylistId) as LocalPlaylist + const currentPlaylist = localPlaylists.playlists.find( + (item) => item.id === localPlaylists.currentPlaylistId + ) as LocalPlaylist + if (currentPlaylist.data.length != newPlaylist.length) { + changed = true + } newPlaylist.forEach((item, index) => { - const playlistItem = currentPlaylist.data[index]; - if (playlistItem === undefined || item.id !== playlistItem.id){ + const playlistItem = currentPlaylist.data[index] + if (playlistItem === undefined || item.id !== playlistItem.id) { changed = true } }) - if (changed){ + if (changed) { + // console.log('changed') dispatch(savePlaylist(newPlaylist)) } }, [playlist]) useEffect(() => { localBlobsRef.current = localBlobs - }, [localBlobs, playlist]) + configRef.current = config + }, [localBlobs, config]) return <></> } diff --git a/src/js/channel.ts b/src/js/channel.ts index 11d66ad..bfb06b7 100644 --- a/src/js/channel.ts +++ b/src/js/channel.ts @@ -61,6 +61,10 @@ const channelInner = async (id: string, innertube: Innertube | null) => { const videoArr: (VideoResult | undefined)[] = videoRes.videos.map( (video) => { const innerVideo = video as Video + const viewMatch = innerVideo.view_count.text + ?.replaceAll(',', '') + .match(/\d+/) as string[] + const viewNumber = viewMatch[0] as string if (video.type === 'Video') { return { type: 'video', @@ -71,11 +75,7 @@ const channelInner = async (id: string, innertube: Innertube | null) => { videoThumbnails: extractInnertubeThumbnail( innerVideo.thumbnails ), - viewCount: Number( - innerVideo.view_count.text - ?.replace(/ views$/, '') - .replace(/,/g, '') - ), + viewCount: Number(viewNumber), lengthSeconds: toSecond( innerVideo.duration?.text as string ), diff --git a/src/js/search.ts b/src/js/search.ts index bb00ffd..614d6fd 100644 --- a/src/js/search.ts +++ b/src/js/search.ts @@ -28,6 +28,7 @@ import { extractPipedPlaylist, extractPipedVideos, } from '@/utils/extractResults' +import { extractNumber } from '@/utils/format' type InvidiousRes = InvidiousVideo | InvidiousPlaylist | InvidiousChannel type PipedRes = PipedVideo | PipedPlaylist | PipedChannel @@ -111,10 +112,6 @@ async function searchInner( (item) => { if (item.type === 'Video') { const i = item as Video - let views = Number( - i.view_count.text?.replaceAll(',', '').match(/\d+/)[0] - ) - views = isNaN(views) ? 0 : views const newVideo: VideoResult = { type: 'video', title: i.title.text as string, @@ -124,7 +121,7 @@ async function searchInner( videoThumbnails: extractInnertubeThumbnail( i.thumbnails ), - viewCount: views, + viewCount: extractNumber(i.view_count.text as string), lengthSeconds: i.duration.seconds, } // console.log(newVideo) @@ -133,10 +130,6 @@ async function searchInner( // console.log(item) const i = item as Playlist const author = i.author as Author - let videoCount = Number( - i.video_count.text?.replace(/ videos$/, '') - ) - videoCount = isNaN(videoCount) ? 0 : videoCount const newPlaylist: PlaylistResult = { type: 'playlist', title: i.title.text as string, @@ -146,16 +139,11 @@ async function searchInner( playlistThumbnails: extractInnertubeThumbnail( i.thumbnails ), - vidCount: videoCount, + vidCount: extractNumber(i.video_count.text as string), } return newPlaylist } else if (item.type === 'Channel') { const i = item as Channel - let subCount = i.video_count.text?.replace( - / subscribers$/, - '' - ) - subCount = subCount === undefined ? '0' : subCount const newChannel: ChannelResult = { type: 'channel', author: i.author.name, @@ -163,7 +151,9 @@ async function searchInner( channelThumbnails: extractInnertubeThumbnail( i.author.thumbnails ), - subCount: subCount, + subCount: extractNumber( + i.video_count.text as string + ).toString(), } return newChannel } else { diff --git a/src/js/videoDetail.ts b/src/js/videoDetail.ts index 87f5580..7aba22b 100644 --- a/src/js/videoDetail.ts +++ b/src/js/videoDetail.ts @@ -45,7 +45,6 @@ interface InvidiousDetails { hlsUrl?: string adaptiveFormats: { index: string - bitrate: string init: string url: string itag: string @@ -55,6 +54,7 @@ interface InvidiousDetails { projectionType: number container: string encoding: string + bitrate: string qualityLabel?: string resolution?: string }[] diff --git a/src/utils/format.ts b/src/utils/format.ts index c24eb42..f52870d 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -1,3 +1,5 @@ +import { store } from '@/store/store' + const formatViewNumber: (arg0: number) => string = (views) => { if (views > 1000000000) { return `${(views / 1000000000).toFixed(1)}B` @@ -60,4 +62,23 @@ const stringToNumber: (arg0: string) => number = (string) => { return 0 } } -export { formatViewNumber, convertSecond, toSecond, stringToNumber } +const compactNumber: (arg0: number) => string = (number) => { + const storeState = store.getState() + const lang = storeState.config.ui.lang + const formatNumber = new Intl.NumberFormat(lang, { + notation: 'compact', + }) + return formatNumber.format(number) +} +const extractNumber: (arg0: string) => number = (string) => { + const match = string.replaceAll(',', '').match(/\d+/) as string[] + return Number(match[0]) +} +export { + formatViewNumber, + convertSecond, + toSecond, + stringToNumber, + compactNumber, + extractNumber, +} diff --git a/src/utils/thumbnailExtract.tsx b/src/utils/thumbnailExtract.ts similarity index 64% rename from src/utils/thumbnailExtract.tsx rename to src/utils/thumbnailExtract.ts index 940ad45..90b9d62 100644 --- a/src/utils/thumbnailExtract.tsx +++ b/src/utils/thumbnailExtract.ts @@ -6,30 +6,42 @@ export const extractInnertubeThumbnail = ( ) => { const maxresThumbnail: Thumbnail = { quality: 'maxres', - url: array[0].url, + url: fixChannelThumbnail(array[0].url), height: array[0].height, width: array[0].width, } const mediumThumbnail: Thumbnail = array[1] ? { quality: 'medium', - url: array[1].url, + url: fixChannelThumbnail(array[1].url), height: array[1].height, width: array[1].width, } : { ...maxresThumbnail, quality: 'medium' } return [maxresThumbnail, mediumThumbnail] } +const fixChannelThumbnail = (string: string) => { + // Sometimes invidious and innertube will pass a thumbnail with a url starts with // instead of https:// + return string.replace(/^\/\//, 'https://') +} export const extractInvidiousChannelThumbnail = ( array: { url: string; width: number; height: number }[] ) => { const thumbnails = array.sort((a, b) => b.width - a.width) return thumbnails.map((state, index) => index === 0 - ? { ...state, quality: 'maxres' } + ? { + ...state, + quality: 'maxres', + url: fixChannelThumbnail(state.url), + } : index === 1 - ? { ...state, quality: 'medium' } - : { ...state, quality: '' } + ? { + ...state, + quality: 'medium', + url: fixChannelThumbnail(state.url), + } + : { ...state, quality: '', url: fixChannelThumbnail(state.url) } ) } export const generatePipedThumbnail = (url: string) => { diff --git a/src/views/DetailView.tsx b/src/views/DetailView.tsx index e4da845..d6aef92 100644 --- a/src/views/DetailView.tsx +++ b/src/views/DetailView.tsx @@ -96,6 +96,7 @@ export default function DetailView(props: DetailViewProps): ReactElement { break default: } + presentToast('success', t('video-detail:Copied-Link')) } const openLink = (type: string) => { const invidiousUrl = config.instance.preferType.find( @@ -213,6 +214,10 @@ export default function DetailView(props: DetailViewProps): ReactElement { handleAddToPlaylist(false) } > + <Icon + f7="plus_rectangle_fill" + className="mr-2 text-[1.2rem]" + /> {t('search-result:Add-to-playlist')} </Button> <Button @@ -221,17 +226,29 @@ export default function DetailView(props: DetailViewProps): ReactElement { handleAddToPlaylist(true) } > + <Icon + f7="arrow_right_to_line" + className="mr-2 text-[1.2rem]" + /> {t('search-result:Add-to-next-song')} </Button> </div> <div className="flex gap-8 w-full"> <Button fill popoverOpen=".copy-popover"> + <Icon + f7="doc_on_clipboard_fill" + className="mr-2 text-[1.2rem]" + /> {t('search-result:Copy-Link')} </Button> <Button fill popoverOpen=".open-link-popover" > + <Icon + f7="link" + className="mr-2 text-[1.2rem]" + /> {t('search-result:Open-link')} </Button> </div> @@ -268,19 +285,33 @@ export default function DetailView(props: DetailViewProps): ReactElement { > <List> <ListItem - popoverClose - title="Youtube" + className="popover-close flex justify-start" onClick={() => copyLink('youtube')} link="#" noChevron={true} - ></ListItem> + > + <div className="flex justify-start"> + <Icon + f7="square_arrow_up_fill" + className="mr-2 text-[1.2rem]" + /> + <p>Youtube</p> + </div> + </ListItem> <ListItem - popoverClose - title="Invidious" + className="popover-close" onClick={() => copyLink('invidious')} link="#" noChevron={true} - ></ListItem> + > + <div className="flex justify-start"> + <Icon + f7="smiley_fill" + className="mr-2 text-[1.2rem]" + /> + <p>Invidious</p> + </div> + </ListItem> </List> </Popover> <Popover @@ -290,19 +321,33 @@ export default function DetailView(props: DetailViewProps): ReactElement { > <List> <ListItem - popoverClose - title="Youtube" + className="popover-close" onClick={() => openLink('youtube')} link="#" noChevron={true} - ></ListItem> + > + <div className="flex justify-start"> + <Icon + f7="square_arrow_up_fill" + className="mr-2 text-[1.2rem]" + /> + <p>Youtube</p> + </div> + </ListItem> <ListItem - popoverClose - title="Invidious" + className="popover-close" onClick={() => openLink('invidious')} link="#" noChevron={true} - ></ListItem> + > + <div className="flex justify-start"> + <Icon + f7="smiley_fill" + className="mr-2 text-[1.2rem]" + /> + <p>Invidious</p> + </div> + </ListItem> </List> </Popover> </> diff --git a/src/views/HomePage.tsx b/src/views/HomePage.tsx index 18c976d..7839aca 100644 --- a/src/views/HomePage.tsx +++ b/src/views/HomePage.tsx @@ -1,5 +1,10 @@ import MainNav from '@/components/MainNav' -import React, { useState, type ReactElement, useEffect, BaseSyntheticEvent } from 'react' +import React, { + useState, + type ReactElement, + useEffect, + BaseSyntheticEvent, +} from 'react' import { Block, Tabs, Tab, View, Toolbar, f7 } from 'framework7-react' import ToolbarPlayer from '@/components/ToolbarPlayer' import NowPlaying from './NowPlaying' @@ -29,8 +34,8 @@ export default function HomePage(props: HomePageProps): ReactElement { setTab('main') } f7.views.get('#page-router').on('routeChange', routerListener) - return () => f7.views.get('#page-router').off('routeChange', routerListener) - + return () => + f7.views.get('#page-router').off('routeChange', routerListener) }, []) return ( <> diff --git a/src/views/NowPlaying-modules/PlayingSlider.tsx b/src/views/NowPlaying-modules/PlayingSlider.tsx new file mode 100644 index 0000000..f3eb603 --- /dev/null +++ b/src/views/NowPlaying-modules/PlayingSlider.tsx @@ -0,0 +1,31 @@ +import { convertSecond } from '@/utils/format' +import { Range } from 'framework7-react' +import React, { type ReactElement } from 'react' + +export interface PlayingSliderProps { + audio: HTMLAudioElement +} + +export default function PlayingSlider(props: PlayingSliderProps): ReactElement { + const handleSliderChange = (duration: number) => { + props.audio.currentTime = duration + } + const formatLabel = (e: number) => { + return convertSecond(e) + } + return ( + <> + <div className="px-10"> + {!isNaN(props.audio.duration) && ( + <Range + max={props.audio.duration} + value={props.audio.currentTime} + onRangeChanged={handleSliderChange} + label={true} + formatLabel={formatLabel} + ></Range> + )} + </div> + </> + ) +} diff --git a/src/views/NowPlaying.tsx b/src/views/NowPlaying.tsx index 1e3bab7..4cd0f32 100644 --- a/src/views/NowPlaying.tsx +++ b/src/views/NowPlaying.tsx @@ -15,6 +15,7 @@ import Wavesurfer from '@/views/NowPlaying-modules/Wavesurfer' import NoPlaying from '@/views/NowPlaying-modules/NoPlaying' import { selectConfig } from '@/store/globalConfig' import { convertSecond } from '@/utils/format' +import PlayingSlider from './NowPlaying-modules/PlayingSlider' export default function NowPlaying(): ReactElement { const playerState = useSelector(selectPlayer) @@ -165,10 +166,14 @@ export default function NowPlaying(): ReactElement { {playerState.currentPlaying?.title} </h5> </div> - <Wavesurfer - media={audio.current} - showTimeline={config.nowPlaying.showTimeline} - /> + {audio.current.duration < 90 * 40 ? ( + <Wavesurfer + media={audio.current} + showTimeline={config.nowPlaying.showTimeline} + /> + ) : ( + <PlayingSlider audio={audio.current} /> + )} <a className="text-lg flex mt-4 items-center justify-center cursor-pointer" onClick={handleChangeTimestampStyle} diff --git a/src/views/PlayList.tsx b/src/views/PlayList.tsx index 7c7d43f..cb12d7d 100644 --- a/src/views/PlayList.tsx +++ b/src/views/PlayList.tsx @@ -5,7 +5,7 @@ import { useSelector, useDispatch } from 'react-redux' import { selectPlaylist, setItemPlaying, sort } from '@/store/playlistReducers' import { play, selectPlayer } from '@/store/playerReducers' import PlayItemInner from '@/components/PlayItemInner' -import PlaylistControlBar from '@/components/PlaylistControlBar' +import PlaylistControlBar from '@/views/Playlist-modules/PlaylistControlBar' import { useTranslation } from 'react-i18next' export interface PlayListProps {} diff --git a/src/components/PlaylistControlBar.tsx b/src/views/Playlist-modules/PlaylistControlBar.tsx similarity index 70% rename from src/components/PlaylistControlBar.tsx rename to src/views/Playlist-modules/PlaylistControlBar.tsx index 7768354..2e0336b 100644 --- a/src/components/PlaylistControlBar.tsx +++ b/src/views/Playlist-modules/PlaylistControlBar.tsx @@ -1,12 +1,7 @@ import React, { BaseSyntheticEvent, type ReactElement } from 'react' import { Button, Icon, List, ListItem, Popover, f7 } from 'framework7-react' import { useDispatch, useSelector } from 'react-redux' -import { - clearErrorItems, - clearPlayedItems, - clearAllItems, - shuffleUnplayed, -} from '@/store/playlistReducers' +import { clearAllItems, shuffleUnplayed } from '@/store/playlistReducers' import { setSong, stop } from '@/store/playerReducers' import { useTranslation } from 'react-i18next' import { @@ -17,7 +12,9 @@ import { } from '@/store/localPlaylistReducers' import { LocalPlaylist } from '@/typescript/interfaces' import { changeCurrentPlaylist } from '@/store/localPlaylistReducers' -import presentToast from './Toast' +import presentToast from '@/components/Toast' +import RemoveButton from './RemoveButton' +import RandomButton from './RandomButton' export interface PlaylistControlBarProps {} @@ -26,17 +23,6 @@ export default function PlaylistControlBar(): ReactElement { const localPlaylist = useSelector(selectLocalPlaylist) const { t } = useTranslation(['playlist']) - const handleClearPlaylist = () => { - f7.dialog.confirm( - t('playlist:Are-you-sure-to-clear-the-playlist'), - t('playlist:Clear-playlist'), - () => { - dispatch(clearAllItems()) - dispatch(setSong(undefined)) - dispatch(stop()) - } - ) - } const getCurrentPlaylistName = () => { const currentPlaylist = localPlaylist.playlists.find( (item) => item.id === localPlaylist.currentPlaylistId @@ -134,34 +120,8 @@ export default function PlaylistControlBar(): ReactElement { </div> </div> <div className="flex h-full justify-around items-center py-2 m-0 flex-wrap gap-2"> - <Button - className="m-0" - tooltip={t('playlist:Clear-Error-items')} - onClick={() => dispatch(clearErrorItems())} - > - <Icon className="text-[1.5rem]" f7="flag_slash_fill"></Icon> - </Button> - <Button - className="m-0" - tooltip={t('playlist:Clear-played-items')} - onClick={() => dispatch(clearPlayedItems())} - > - <Icon className="text-[1.5rem]" f7="flowchart_fill"></Icon> - </Button> - <Button - className="m-0" - tooltip={t('playlist:Shuffle-unplayed-items')} - onClick={() => dispatch(shuffleUnplayed())} - > - <Icon className="text-[1.5rem]" f7="shuffle"></Icon> - </Button> - <Button - className="m-0" - tooltip={t('playlist:Clear-playlist')} - onClick={handleClearPlaylist} - > - <Icon className="text-[1.5rem]" f7="trash"></Icon> - </Button> + <RandomButton /> + <RemoveButton /> </div> <Popover className="playlist-popover" @@ -169,13 +129,23 @@ export default function PlaylistControlBar(): ReactElement { arrow={false} > <List className="cursor-pointer"> - <ListItem onClick={handleRemovePlaylist} popoverClose> - <Icon f7="minus" /> - <p>{t('playlist:Remove-playlist')}</p> + <ListItem + onClick={handleRemovePlaylist} + className="popover-close" + > + <div className="flex justify-start"> + <Icon f7="minus" className="mr-4 text-[1.2rem]" /> + <p>{t('playlist:Remove-playlist')}</p> + </div> </ListItem> - <ListItem onClick={handleRenamePlaylist} popoverClose> - <Icon f7="pencil" /> - <p>{t('playlist:Rename-playlist')}</p> + <ListItem + onClick={handleRenamePlaylist} + className="popover-close" + > + <div className="flex justify-start"> + <Icon f7="pencil" className="mr-4 text-[1.2rem]" /> + <p>{t('playlist:Rename-playlist')}</p> + </div> </ListItem> </List> </Popover> diff --git a/src/views/Playlist-modules/RandomButton.tsx b/src/views/Playlist-modules/RandomButton.tsx new file mode 100644 index 0000000..d81b6b2 --- /dev/null +++ b/src/views/Playlist-modules/RandomButton.tsx @@ -0,0 +1,51 @@ +import { shuffleAll, shuffleUnplayed } from '@/store/playlistReducers' +import { Button, Icon, List, ListItem, Popover, f7 } from 'framework7-react' +import React, { type ReactElement } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' + +export interface RandomButtonProps {} + +export default function RandomButton(): ReactElement { + const { t } = useTranslation() + const dispatch = useDispatch() + return ( + <> + <Button + popoverOpen=".random-popover" + tooltip={t('playlist:Randomize-items')} + className="m-0" + > + <Icon f7="shuffle" className="text-[1.2rem]"></Icon> + </Button> + <Popover className="random-popover" backdrop={false} arrow={false}> + <List className="cursor-pointer"> + <ListItem + onClick={() => dispatch(shuffleUnplayed())} + className="popover-close" + > + <div className="flex justify-start"> + <Icon + f7="music_note_list" + className="mr-4 text-[1.2rem]" + /> + <p>{t('playlist:Shuffle-unplayed-items')}</p> + </div> + </ListItem> + <ListItem + onClick={() => dispatch(shuffleAll())} + className="popover-close" + > + <div className="flex justify-start"> + <Icon + f7="question_diamond" + className="mr-4 text-[1.2rem]" + /> + <p>{t('playlist:Shuffle-all-items')}</p> + </div> + </ListItem> + </List> + </Popover> + </> + ) +} diff --git a/src/views/Playlist-modules/RemoveButton.tsx b/src/views/Playlist-modules/RemoveButton.tsx new file mode 100644 index 0000000..d48693e --- /dev/null +++ b/src/views/Playlist-modules/RemoveButton.tsx @@ -0,0 +1,76 @@ +import { setSong, stop } from '@/store/playerReducers' +import { + clearAllItems, + clearErrorItems, + clearPlayedItems, +} from '@/store/playlistReducers' +import { Button, Icon, List, ListItem, Popover, f7 } from 'framework7-react' +import React, { type ReactElement } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' + +export interface RemoveButtonProps {} + +export default function RemoveButton(): ReactElement { + const { t } = useTranslation() + const dispatch = useDispatch() + const handleClearPlaylist = () => { + f7.dialog.confirm( + t('playlist:Are-you-sure-to-clear-the-playlist'), + t('playlist:Clear-playlist'), + () => { + dispatch(clearAllItems()) + dispatch(setSong(undefined)) + dispatch(stop()) + } + ) + } + return ( + <> + <Button + popoverOpen=".remove-popover" + tooltip={t('playlist:Remove-Items')} + className="m-0" + > + <Icon f7="trash" className="text-[1.5rem]"></Icon> + </Button> + <Popover className="remove-popover" backdrop={false} arrow={false}> + <List className="cursor-pointer"> + <ListItem + onClick={() => dispatch(clearErrorItems())} + className="popover-close" + > + <div className="flex justify-start"> + <Icon + f7="flag_slash" + className="mr-4 text-[1.2rem]" + /> + <p>{t('playlist:Clear-Error-items')}</p> + </div> + </ListItem> + <ListItem + onClick={() => dispatch(clearPlayedItems())} + className="popover-close" + > + <div className="flex justify-start"> + <Icon + f7="flowchart_fill" + className="mr-4 text-[1.2rem]" + /> + <p>{t('playlist:Clear-played-items')}</p> + </div> + </ListItem> + <ListItem + onClick={handleClearPlaylist} + className="popover-close" + > + <div className="flex justify-start"> + <Icon f7="xmark" className="mr-4 text-[1.2rem]" /> + <p>{t('playlist:Clear-playlist')}</p> + </div> + </ListItem> + </List> + </Popover> + </> + ) +} diff --git a/src/views/Search-modules/NoResult.tsx b/src/views/Search-modules/NoResult.tsx index 5242711..dc00275 100644 --- a/src/views/Search-modules/NoResult.tsx +++ b/src/views/Search-modules/NoResult.tsx @@ -1,7 +1,7 @@ import { Icon } from 'framework7-react' import React, { type ReactElement } from 'react' import { useTranslation } from 'react-i18next' -import {Page} from 'framework7-react' +import { Page } from 'framework7-react' export interface NoResultProps {} @@ -9,26 +9,28 @@ export default function NoResult(): ReactElement { const { t } = useTranslation(['search-result']) return ( <Page className="h-page"> - <section className="w-full h-full flex justify-center items-center"> - <div className="w-full h-fit flex flex-wrap justify-center flex-row items-center gap-8 my-auto"> - <div className="w-full"> - <Icon - className="text-4xl lg:text-6xl w-full" - f7="search_circle" - /> + <section className="w-full h-full flex justify-center items-center"> + <div className="w-full h-fit flex flex-wrap justify-center flex-row items-center gap-8 my-auto"> + <div className="w-full"> + <Icon + className="text-4xl lg:text-6xl w-full" + f7="search_circle" + /> + </div> + <div className="w-full"> + <h1 className="text-3xl lg:text-5xl w-full text-center"> + {t('search-result:No-search-results')} + </h1> + </div> + <div className="w-full"> + <h3 className="text-xl lg:text-3xl w-full text-center"> + {t( + 'search-result:Search-something-on-the-search-bar' + )} + </h3> + </div> </div> - <div className="w-full"> - <h1 className="text-3xl lg:text-5xl w-full text-center"> - {t('search-result:No-search-results')} - </h1> - </div> - <div className="w-full"> - <h3 className="text-xl lg:text-3xl w-full text-center"> - {t('search-result:Search-something-on-the-search-bar')} - </h3> - </div> - </div> - </section> + </section> </Page> ) } diff --git a/src/views/Setting-modules/InstanceSetting.tsx b/src/views/Setting-modules/InstanceSetting.tsx index ce87d61..29db90e 100644 --- a/src/views/Setting-modules/InstanceSetting.tsx +++ b/src/views/Setting-modules/InstanceSetting.tsx @@ -12,7 +12,15 @@ import { updateInstancei18n, } from '@/store/globalConfig' import { Instance } from '@/typescript/interfaces' -import { Block, List, ListItem, BlockTitle, f7, Button } from 'framework7-react' +import { + Block, + List, + ListItem, + BlockTitle, + f7, + Button, + Icon, +} from 'framework7-react' import { useTranslation } from 'react-i18next' import { Store, useCustomContext } from '@/store/reactContext' import Innertube from 'youtubei.js/agnostic' @@ -32,7 +40,6 @@ export default function InstanceSetting(): ReactElement { ) const { instanceList, - innertube, }: { instanceList: Instance[] innertube: React.RefObject<Innertube | null> @@ -291,6 +298,10 @@ export default function InstanceSetting(): ReactElement { shell.openExternal('https://api.invidious.io/') } > + <Icon + f7="arrow_up_right_square" + className="mr-2 text-[1.2rem]" + /> {t('setting:View-Invidious-Instances')} </Button> <Button @@ -301,9 +312,17 @@ export default function InstanceSetting(): ReactElement { ) } > + <Icon + f7="arrow_up_right_square" + className="mr-2 text-[1.2rem]" + /> {t('setting:View-Piped-Instances')} </Button> <Button fill onClick={resetInstances}> + <Icon + f7="arrow_counterclockwise" + className="mr-2 text-[1.2rem]" + /> {t('setting:Reset-to-default')} </Button> </Block> diff --git a/src/views/Setting-modules/StorageManagement.tsx b/src/views/Setting-modules/StorageManagement.tsx new file mode 100644 index 0000000..041e947 --- /dev/null +++ b/src/views/Setting-modules/StorageManagement.tsx @@ -0,0 +1,459 @@ +import presentToast from '@/components/Toast' +import { getPlayitem } from '@/js/fetchInfo' +import { deleteBlob, selectLocalBlobs } from '@/store/blobStorage' +import { selectConfig } from '@/store/globalConfig' +import { addToPlaylist, selectPlaylist } from '@/store/playlistReducers' +import { Store, useCustomContext } from '@/store/reactContext' +import { LocalBlobEntry } from '@/typescript/interfaces' +import { + Block, + Button, + Card, + CardContent, + CardHeader, + Checkbox, + Icon, + Link, + NavRight, + Navbar, + Page, + f7, +} from 'framework7-react' +import React, { useState, type ReactElement, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch, useSelector } from 'react-redux' +import Innertube from 'youtubei.js/agnostic' + +export interface StorageManagementProps {} +interface ContentRecord extends LocalBlobEntry { + exist: boolean + selected: boolean +} + +export default function StorageManagement( + props: StorageManagementProps +): ReactElement { + const { t } = useTranslation(['storage']) + const config = useSelector(selectConfig) + const localBlobs = useSelector(selectLocalBlobs) + const playlist = useSelector(selectPlaylist) + const { innertube }: { innertube: React.RefObject<Innertube | null> } = + useCustomContext(Store) + const dispatch = useDispatch() + const [refresh, triggerRefresh] = useState(true) + const [fileList, setFileList] = useState<ContentRecord[]>([]) + const [sortState, setSortState] = useState({ + col: 'lastAccess', + order: 'dec', + }) + /* eslint-disable-next-line @typescript-eslint/no-var-requires */ + const { ipcRenderer } = require('electron') + + const displayDate = (number: number) => { + const dateTimeFormat = new Intl.DateTimeFormat(config.ui.lang, { + dateStyle: 'short', + timeStyle: 'short', + }) + const date = new Date(number) + return dateTimeFormat.format(date) + } + const handleTopCheckboxChange = () => { + if (fileList.every((file) => file.selected)) { + setFileList((prevState) => + prevState.map((file) => { + return { ...file, selected: false } + }) + ) + } else { + setFileList((prevState) => + prevState.map((file) => { + return { + ...file, + selected: true, + } + }) + ) + } + } + const handleRemoveAll = () => { + f7.dialog.confirm( + t( + 'storage:This-will-remove-all-files-in-the-download-folder-Are-you-sure-to-proceed?' + ), + () => { + fileList.forEach((file) => { + ipcRenderer.send('delete-blob', file) + dispatch(deleteBlob(file.id)) + }) + } + ) + } + const handleRemoveUnhandled = () => { + fileList + .filter((file) => file.created === 0) + .forEach((file) => { + ipcRenderer.send('delete-blob', file) + }) + triggerRefresh((prevState) => !prevState) // Trigger the useEffect as sending message over ipc channel will neither change the localBlob entries nor trigger a re-render + presentToast('success', t('storage:Removed-files-without-entries')) + } + const handleRemoveNonExist = () => { + fileList + .filter((file) => file.exist === false) + .forEach((file) => { + dispatch(deleteBlob(file.id)) + }) + presentToast('success', t('storage:Removed-entries-without-files')) + } + const handleRemoveSelected = () => { + fileList + .filter((file) => file.selected) + .forEach((file) => { + ipcRenderer.send('delete-blob', file) + dispatch(deleteBlob(file.id)) + }) + presentToast('success', t('storage:Removed-selected-files')) + } + const handleAddSelected = () => { + let count = 0 + fileList + .filter((file) => file.selected) + .forEach((file) => { + if (!playlist.some((playitem) => playitem.id === file.id)) { + // Do not add the item if already on playlist + getPlayitem( + file.id, + config.instance.preferType, + innertube.current + ) + .then((item) => { + if (item instanceof Error) { + throw item + } + count++ + dispatch(addToPlaylist(item)) + }) + .catch((err) => presentToast('error', err)) + .finally(() => { + presentToast( + 'success', + t('storage:Added-{{count}}-items-to-playlist', { + count: count, + }) + ) + }) + } + }) + } + useEffect(() => { + let folderContent: string[] + ipcRenderer.invoke('get-folder-content').then((list: string[]) => { + folderContent = list + const contentRecord: ContentRecord[] = localBlobs.map((record) => { + const fileExist = folderContent.some((content: string) => { + const fileExtension = record.extension.includes('mp4') + ? 'm4a' + : 'opus' + return content === `${record.id}.${fileExtension}` + }) + return { ...record, exist: fileExist, selected: false } + }) // Search for existance of all localBlobs + folderContent.forEach((file) => { + const id = file.replace(/\..*$/, '') + const ext = file.replace(/^.*\./, '') + if (!contentRecord.some((item) => item.id === id)) { + contentRecord.push({ + title: t('storage:Unknown'), + id: id, + extension: ext === 'm4a' ? 'mp4' : 'opus', + created: 0, + lastAccess: 0, + exist: true, + selected: false, + }) + } + }) + setFileList( + contentRecord.sort((a, b) => b.lastAccess - a.lastAccess) + ) + }) + }, [localBlobs, refresh]) + + const sortBy = (type: string) => { + let sorted: ContentRecord[] + const newSortState = { + col: type, + order: + sortState.col === type && sortState.order === 'dec' + ? 'acc' + : 'dec', + } + switch (type) { + case 'title': + if (newSortState.order === 'acc') { + sorted = [...fileList].sort((a, b) => + a.title.localeCompare(b.title) + ) + } else { + sorted = [...fileList].sort((a, b) => + b.title.localeCompare(a.title) + ) + } + break + case 'id': + if (newSortState.order === 'acc') { + sorted = [...fileList].sort((a, b) => + a.id.localeCompare(b.id) + ) + } else { + sorted = [...fileList].sort((a, b) => + b.id.localeCompare(a.id) + ) + } + break + case 'create': + if (newSortState.order === 'acc') { + sorted = [...fileList].sort((a, b) => a.created - b.created) + } else { + sorted = [...fileList].sort((a, b) => b.created - a.created) + } + break + case 'lastAccess': + if (newSortState.order === 'acc') { + sorted = [...fileList].sort( + (a, b) => a.lastAccess - b.lastAccess + ) + } else { + sorted = [...fileList].sort( + (a, b) => b.lastAccess - a.lastAccess + ) + } + break + case 'exist': + if (newSortState.order === 'acc') { + sorted = [...fileList].sort((a, b) => + a === b ? 0 : a ? -1 : 1 + ) + } else { + sorted = [...fileList].sort((a, b) => + a === b ? 0 : b ? -1 : 1 + ) + } + break + default: + sorted = fileList + } + setFileList(sorted) + setSortState(newSortState) + } + const selectItem = (id: string) => { + setFileList((prevState) => + prevState.map((file) => + file.id === id ? { ...file, selected: !file.selected } : file + ) + ) + } + return ( + <> + <Page> + <Navbar title={t('storage:Storage-management')}> + <NavRight> + <Link className="color-primary" popupClose> + <Icon f7="xmark" /> + </Link> + </NavRight> + </Navbar> + <Block> + <Card className="data-table data-table-init"> + <CardHeader> + {fileList.some((file) => file.selected) ? ( + // Top status bar when there are items selected + <div className="data-table-header"> + <div className="data-table-title"> + { + fileList.filter( + (file) => file.selected + ).length + }{' '} + {t('storage:items-selected')} + </div> + <div className="data-table-actions"> + <Button + fill + onClick={handleAddSelected} + > + {t( + 'storage:Add-entries-to-current-playlist' + )} + </Button> + <Button + fill + onClick={handleRemoveSelected} + > + {t( + 'storage:Remove-selected-entries' + )} + </Button> + </div> + </div> + ) : ( + // Top status bar when there are no item selected + <div className="data-table-header"> + <div className="data-table-title"> + {t('storage:Audio-files')} + </div> + <div className="data-table-actions"> + <Button + fill + onClick={handleRemoveNonExist} + > + {t( + 'storage:Remove-non-existent-entries' + )} + </Button> + <Button + fill + onClick={handleRemoveUnhandled} + > + {t( + 'storage:Remove-unhandled-entries' + )} + </Button> + <Button fill onClick={handleRemoveAll}> + {t('storage:Remove-all-files')} + </Button> + </div> + </div> + )} + </CardHeader> + <CardContent padding={false}> + <table> + <thead> + <tr> + <th className="checkbox-cell"> + <Checkbox + checked={fileList.every( + (file) => file.selected + )} + indeterminate={ + fileList.some( + (file) => file.selected + ) && + !fileList.every( + (file) => file.selected + ) + } + onChange={ + handleTopCheckboxChange + } + /> + </th> + <th + className={`label-cell sortable-cell ${ + sortState.col === 'title' && + 'sortable-cell-active' + } ${ + sortState.order === 'dec' + ? 'sortable-asc' + : 'sortable-desc' + }`} + onClick={() => sortBy('title')} + > + {t('storage:Title')} + </th> + <th + className={`label-cell sortable-cell large-only ${ + sortState.col === 'id' && + 'sortable-cell-active' + } ${ + sortState.order === 'dec' + ? 'sortable-asc' + : 'sortable-desc' + }`} + onClick={() => sortBy('id')} + > + {t('storage:Video-Id')} + </th> + <th + className={`label-cell sortable-cell ${ + sortState.col === 'create' && + 'sortable-cell-active' + } ${ + sortState.order === 'dec' + ? 'sortable-asc' + : 'sortable-desc' + }`} + onClick={() => sortBy('create')} + > + {t('storage:Created-on')} + </th> + <th + className={`label-cell sortable-cell ${ + sortState.col === + 'lastAccess' && + 'sortable-cell-active' + } ${ + sortState.order === 'dec' + ? 'sortable-asc' + : 'sortable-desc' + }`} + onClick={() => sortBy('lastAccess')} + > + {t('storage:Last-access')} + </th> + <th + className={`label-cell sortable-cell ${ + sortState.col === 'exist' && + 'sortable-cell-active' + } ${ + sortState.order === 'dec' + ? 'sortable-asc' + : 'sortable-desc' + }`} + onClick={() => sortBy('exist')} + > + {t('storage:File-exist')} + </th> + </tr> + </thead> + <tbody> + {fileList.map((file) => ( + <tr key={file.id}> + <td className="checkbox-cell"> + <Checkbox + checked={file.selected} + onChange={() => + selectItem(file.id) + } + /> + </td> + <td className="label-cell"> + {file.title} + </td> + <td className="label-cell large-only"> + {file.id} + </td> + <td className="label-cell"> + {displayDate(file.created)} + </td> + <td className="label-cell"> + {displayDate(file.lastAccess)} + </td> + <td className="label-cell"> + {file.exist ? ( + <Icon f7="checkmark" /> + ) : ( + <Icon f7="xmark" /> + )} + </td> + </tr> + ))} + </tbody> + </table> + </CardContent> + </Card> + </Block> + </Page> + </> + ) +} diff --git a/src/views/Setting-modules/StorageSetting.tsx b/src/views/Setting-modules/StorageSetting.tsx index c5723ff..f24d208 100644 --- a/src/views/Setting-modules/StorageSetting.tsx +++ b/src/views/Setting-modules/StorageSetting.tsx @@ -1,5 +1,5 @@ import React, { BaseSyntheticEvent, type ReactElement } from 'react' -import { List, ListItem } from 'framework7-react' +import { Block, Button, Icon, List, ListItem, Popup } from 'framework7-react' import { useDispatch, useSelector } from 'react-redux' import { changeStorage, @@ -8,6 +8,9 @@ import { } from '@/store/globalConfig' import { useTranslation } from 'react-i18next' import presentToast from '@/components/Toast' +import StorageManagement from './StorageManagement' +/* eslint-disable-next-line @typescript-eslint/no-var-requires */ +const { shell, ipcRenderer } = require('electron') export default function StorageSetting(): ReactElement { const config = useSelector(selectConfig) @@ -25,6 +28,11 @@ export default function StorageSetting(): ReactElement { dispatch(changeStorage(e.target.value)) } } + const handleOpenFolder = () => { + ipcRenderer.invoke('get-folder-path').then((path: string) => { + shell.openExternal(`file://${path}`) + }) + } return ( <> <List className="p-6"> @@ -47,6 +55,23 @@ export default function StorageSetting(): ReactElement { </div> </ListItem> </List> + <Block className="flex justify-center items-center gap-8"> + <Button fill onClick={handleOpenFolder}> + <Icon f7="folder_fill" className="mr-2 text-[1.2rem]" /> + {t('setting:Show-stored-files')} + </Button> + <Button fill popupOpen=".storage-management-popup"> + <Icon + f7="square_stack_3d_down_right_fill" + className="mr-2 text-[1.2rem]" + /> + {t('setting:Manage-stored-files')} + </Button> + </Block> + <Block className="h-20"></Block> + <Popup className="storage-management-popup" tabletFullscreen={true}> + <StorageManagement /> + </Popup> </> ) } diff --git a/tsconfig_old.json b/tsconfig_old.json deleted file mode 100644 index 8815704..0000000 --- a/tsconfig_old.json +++ /dev/null @@ -1,111 +0,0 @@ -{ - "compilerOptions": { - /* Visit https://aka.ms/tsconfig to read more about this file */ - - /* Projects */ - // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ - // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ - // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ - // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ - // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ - // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ - - /* Language and Environment */ - "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, - // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ - "jsx": "react" /* Specify what JSX code is generated. */, - // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ - // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ - // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ - // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ - // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ - // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ - // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ - // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ - // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ - - /* Modules */ - "module": "commonjs" /* Specify what module code is generated. */, - // "rootDir": "./", /* Specify the root folder within your source files. */ - // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ - // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ - "paths": { - "@/*": ["./src/*"] - } /* Specify a set of entries that re-map imports to additional lookup locations. */, - // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ - // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ - // "types": [], /* Specify type package names to be included without being referenced in a source file. */ - // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ - // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ - // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ - // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ - // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ - // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ - // "resolveJsonModule": true, /* Enable importing .json files. */ - // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ - // "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */ - - /* JavaScript Support */ - // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ - // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ - // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ - - /* Emit */ - // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ - // "declarationMap": true, /* Create sourcemaps for d.ts files. */ - // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ - // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ - // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ - // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ - // "outDir": "./", /* Specify an output folder for all emitted files. */ - // "removeComments": true, /* Disable emitting comments. */ - // "noEmit": true, /* Disable emitting files from a compilation. */ - // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ - // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ - // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ - // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ - // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ - // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ - // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ - // "newLine": "crlf", /* Set the newline character for emitting files. */ - // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ - // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ - // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ - // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ - // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ - // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ - - /* Interop Constraints */ - // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ - // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ - // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ - "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, - // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ - "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, - - /* Type Checking */ - "strict": true /* Enable all strict type-checking options. */, - // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ - // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ - // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ - // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ - // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ - // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ - // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ - // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ - // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ - // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ - // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ - // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ - // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ - // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ - // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ - // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ - // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ - // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ - - /* Completeness */ - // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */ - } -}