feat(mobile): audio record && transcribe (#12105)

* feat: audio transcribe

* enhance(mobile): auto start recording on initialization

* fix(mobile): can't delete journal from selection bar

* fix: duplicated audio record buttons in quick add

* fix(mobile): inactive bottom tab color

* enhance(mobile): display no results when there's no matched items

* enhance(mobile): add audio transcription feature and enhance audio component

* fix: store assets directly instead in today page instead of node ref

* save transcribed text to audio's child block

* enhance: transcribe supports punctuations and being offline only

* fix(mobile): save assets to current editing page

---------

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
This commit is contained in:
Charlie
2025-09-16 16:09:29 +08:00
committed by GitHub
parent ecc182879a
commit e103593c0d
31 changed files with 640 additions and 463 deletions

View File

@@ -21,7 +21,6 @@ dependencies {
implementation project(':capacitor-share')
implementation project(':capacitor-splash-screen')
implementation project(':capacitor-status-bar')
implementation project(':capacitor-voice-recorder')
implementation project(':send-intent')
implementation project(':jcesarmobile-ssl-skip')

View File

@@ -9,6 +9,7 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<application
android:allowBackup="true"

View File

@@ -47,10 +47,6 @@
"pkg": "@capacitor/status-bar",
"classpath": "com.capacitorjs.plugins.statusbar.StatusBarPlugin"
},
{
"pkg": "capacitor-voice-recorder",
"classpath": "com.tchvu3.capacitorvoicerecorder.VoiceRecorder"
},
{
"pkg": "send-intent",
"classpath": "de.mindlib.sendIntent.SendIntent"

View File

@@ -38,9 +38,6 @@ project(':capacitor-splash-screen').projectDir = new File('../node_modules/@capa
include ':capacitor-status-bar'
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
include ':capacitor-voice-recorder'
project(':capacitor-voice-recorder').projectDir = new File('../node_modules/capacitor-voice-recorder/android')
include ':send-intent'
project(':send-intent').projectDir = new File('../node_modules/send-intent/android')

View File

@@ -130,6 +130,8 @@ const common = {
'node_modules/prop-types/prop-types.min.js',
'node_modules/interactjs/dist/interact.min.js',
'node_modules/photoswipe/dist/umd/*.js',
'node_modules/wavesurfer.js/dist/wavesurfer.min.js',
'node_modules/wavesurfer.js/dist/plugins/record.min.js',
'packages/amplify/dist/amplify.js',
'packages/ui/dist/ui/ui.js',
'node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3.wasm',

View File

@@ -3,120 +3,122 @@
<plist version="1.0">
<dict>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>APFiles</key>
<dict>
<key>APFileDescriptionKey</key>
<string></string>
<key>APFileDestinationPath</key>
<string></string>
<key>APFileName</key>
<string></string>
<key>APFileSourcePath</key>
<string></string>
</dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Logseq</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>CFBundleURLName</key>
<string>com.logseq.logseq</string>
<key>CFBundleURLSchemes</key>
<array>
<string>logseq</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string></string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UIFileSharingEnabled</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>We will access your camera when you take a photo, and embed it in your note.</string>
<key>NSDocumentsFolderUsageDescription</key>
<string></string>
<key>NSDownloadsFolderUsageDescription</key>
<string></string>
<key>NSFileProviderDomainUsageDescription</key>
<string></string>
<key>NSFileProviderPresenceUsageDescription</key>
<string></string>
<key>NSMicrophoneUsageDescription</key>
<string>We will access your microphone to record audio notes</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>We will access your album when you save a photo.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>We will access your album when you choose a photo, and embed it in your note.</string>
<key>NSUbiquitousContainers</key>
<dict>
<key>iCloud.com.logseq.logseq</key>
<dict>
<key>NSUbiquitousContainerIsDocumentScopePublic</key>
<true/>
<key>NSUbiquitousContainerName</key>
<string>Logseq</string>
<key>NSUbiquitousContainerSupportedFolderLevels</key>
<string>ANY</string>
</dict>
</dict>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportsDocumentBrowser</key>
<true/>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>CFBundleGetInfoString</key>
<string></string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>APFiles</key>
<dict>
<key>APFileDescriptionKey</key>
<string></string>
<key>APFileDestinationPath</key>
<string></string>
<key>APFileName</key>
<string></string>
<key>APFileSourcePath</key>
<string></string>
</dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Logseq</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>CFBundleURLName</key>
<string>com.logseq.logseq</string>
<key>CFBundleURLSchemes</key>
<array>
<string>logseq</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string></string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UIFileSharingEnabled</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>We will access your camera when you take a photo, and embed it in your note.</string>
<key>NSDocumentsFolderUsageDescription</key>
<string></string>
<key>NSDownloadsFolderUsageDescription</key>
<string></string>
<key>NSFileProviderDomainUsageDescription</key>
<string></string>
<key>NSFileProviderPresenceUsageDescription</key>
<string></string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>We need access to speech recognition to convert your voice to text.</string>
<key>NSMicrophoneUsageDescription</key>
<string>We will access your microphone to record audio notes</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>We will access your album when you save a photo.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>We will access your album when you choose a photo, and embed it in your note.</string>
<key>NSUbiquitousContainers</key>
<dict>
<key>iCloud.com.logseq.logseq</key>
<dict>
<key>NSUbiquitousContainerIsDocumentScopePublic</key>
<true/>
<key>NSUbiquitousContainerName</key>
<string>Logseq</string>
<key>NSUbiquitousContainerSupportedFolderLevels</key>
<string>ANY</string>
</dict>
</dict>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportsDocumentBrowser</key>
<true/>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>CFBundleGetInfoString</key>
<string></string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
</dict>
</plist>

View File

@@ -7,6 +7,7 @@
import Capacitor
import Foundation
import Speech
func isDarkMode() -> Bool {
if #available(iOS 12.0, *) {
@@ -204,9 +205,82 @@ public class UILocalPlugin: CAPPlugin, CAPBridgedPlugin {
private var datepickerDialogView: UIView?
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "showDatePicker", returnType: CAPPluginReturnPromise)
CAPPluginMethod(name: "showDatePicker", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "transcribeAudio2Text", returnType: CAPPluginReturnPromise)
]
// TODO: switch to use https://developer.apple.com/documentation/speech/speechanalyzer for iOS 26+
//
private func recognizeSpeech(from url: URL, completion: @escaping (String?, Error?) -> Void) {
SFSpeechRecognizer.requestAuthorization { authStatus in
guard authStatus == .authorized else {
completion(nil, NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "语音识别权限未授权"]))
return
}
let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US"))
let request = SFSpeechURLRecognitionRequest(url: url)
// Setting up offline speech recognition
recognizer?.supportsOnDeviceRecognition = true
request.shouldReportPartialResults = false
request.requiresOnDeviceRecognition = true
request.taskHint = .dictation
if #available(iOS 16, *) {
request.addsPunctuation = true
}
recognizer?.recognitionTask(with: request) { result, error in
if let result = result {
let transcription = result.bestTranscription.formattedString
completion(transcription, nil)
} else if let error = error {
completion(nil, error)
}
}
}
}
@objc func transcribeAudio2Text(_ call: CAPPluginCall) {
self.call = call
// arrayBuffer
guard let audioArray = call.getArray("audioData", NSNumber.self) as? [UInt8] else {
call.reject("无效的音频数据")
return
}
// Data
let audioData = Data(audioArray)
//
let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("recordedAudio.m4a")
do {
try audioData.write(to: fileURL)
let fileExists = FileManager.default.fileExists(atPath: fileURL.path)
print("文件是否存在: \(fileExists), 路径: \(fileURL.path)")
if !fileExists {
call.reject("文件保存失败,文件不存在")
return
}
//
self.recognizeSpeech(from: fileURL) { result, error in
if let result = result {
call.resolve(["transcription": result])
} else if let error = error {
call.reject("语音识别失败: \(error.localizedDescription)")
}
}
} catch {
call.reject("保存文件失败: \(error.localizedDescription)")
}
}
@objc func showDatePicker(_ call: CAPPluginCall) {
self.call = call

View File

@@ -23,7 +23,6 @@ def capacitor_pods
pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share'
pod 'CapacitorSplashScreen', :path => '../../node_modules/@capacitor/splash-screen'
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
pod 'CapacitorVoiceRecorder', :path => '../../node_modules/capacitor-voice-recorder'
pod 'SendIntent', :path => '../../node_modules/send-intent'
pod 'JcesarmobileSslSkip', :path => '../../node_modules/@jcesarmobile/ssl-skip'
end

View File

@@ -26,8 +26,6 @@ PODS:
- Capacitor
- CapacitorStatusBar (7.0.1):
- Capacitor
- CapacitorVoiceRecorder (5.0.0):
- Capacitor
- JcesarmobileSslSkip (0.4.0):
- Capacitor
- SendIntent (7.0.0):
@@ -48,7 +46,6 @@ DEPENDENCIES:
- "CapacitorShare (from `../../node_modules/@capacitor/share`)"
- "CapacitorSplashScreen (from `../../node_modules/@capacitor/splash-screen`)"
- "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)"
- CapacitorVoiceRecorder (from `../../node_modules/capacitor-voice-recorder`)
- "JcesarmobileSslSkip (from `../../node_modules/@jcesarmobile/ssl-skip`)"
- SendIntent (from `../../node_modules/send-intent`)
@@ -81,8 +78,6 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@capacitor/splash-screen"
CapacitorStatusBar:
:path: "../../node_modules/@capacitor/status-bar"
CapacitorVoiceRecorder:
:path: "../../node_modules/capacitor-voice-recorder"
JcesarmobileSslSkip:
:path: "../../node_modules/@jcesarmobile/ssl-skip"
SendIntent:
@@ -103,10 +98,9 @@ SPEC CHECKSUMS:
CapacitorShare: 58d6c2da63b093e8693287b2d36db92435538435
CapacitorSplashScreen: 19cd3573e57507e02d6f34597a8c421e00931487
CapacitorStatusBar: 275cbf2f4dfc00388f519ef80c7ec22edda342c9
CapacitorVoiceRecorder: 872ea857b497ce2c71afe3e4eb5de0a74290c0db
JcesarmobileSslSkip: b0f921e9d397a57f7983731209ca1ee244119c1f
SendIntent: 1f4f65c7103eb423067c566682dfcda973b5fb29
PODFILE CHECKSUM: c36fe2977577d9ee26e6a71a903c924657c49bbb
PODFILE CHECKSUM: d1ad773ee5fbd3415c2d78d69f4396a1dc68bed9
COCOAPODS: 1.16.2

View File

@@ -145,7 +145,6 @@
"@tabler/icons-webfont": "^2.47.0",
"@tippyjs/react": "4.2.5",
"bignumber.js": "^9.0.2",
"capacitor-voice-recorder": "^5.0.0",
"check-password-strength": "2.0.7",
"chokidar": "3.5.1",
"chrono-node": "2.2.4",
@@ -192,6 +191,7 @@
"threads": "1.6.5",
"url": "^0.11.0",
"util": "^0.12.5",
"wavesurfer.js": "7.10.1",
"yargs-parser": "20.2.4"
},
"resolutions": {

View File

@@ -15,6 +15,8 @@
<script defer src="./js/interact.min.js"></script>
<script defer src="./js/marked.min.js"></script>
<script defer src="./js/eventemitter3.umd.min.js"></script>
<script defer src="./js/wavesurfer.min.js"></script>
<script defer src="./js/record.min.js"></script>
<script defer src="./js/photoswipe.umd.min.js"></script>
<script defer src="./js/photoswipe-lightbox.umd.min.js"></script>
<script defer src="./js/react.production.min.js"></script>

View File

@@ -459,11 +459,16 @@
(editor-handler/resize-image! config block-id metadata full-text {:width width'})))
(reset! *resizing-image? false))))))])))
(rum/defc audio-cp [src]
;; Change protocol to allow media fragment uris to play
[:audio {:src (string/replace-first src common-config/asset-protocol "file://")
:controls true
:on-touch-start #(util/stop %)}])
(rum/defc audio-cp
([src] (audio-cp src nil))
([src ext]
;; Change protocol to allow media fragment uris to play
(let [src (string/replace-first src common-config/asset-protocol "file://")
opts {:controls true
:on-touch-start #(util/stop %)}]
(case ext
:m4a [:audio opts [:source {:src src :type "audio/mp4"}]]
[:audio (assoc opts :src src)]))))
(defn- open-pdf-file
[e block href]
@@ -524,9 +529,10 @@
(mobile-intent/open-or-share-file asset-url))))]
(cond
(contains? config/audio-formats ext)
(or (contains? config/audio-formats ext)
(and (= ext :webm) (string/starts-with? title "record-")))
(if db-based?
(audio-cp @src)
(audio-cp @src ext)
(file-based-asset-loader @src #(audio-cp @src)))
(contains? config/video-formats ext)
@@ -537,7 +543,7 @@
(if db-based?
(resizable-image config title @src metadata full_text true)
(file-based-asset-loader @src
#(resizable-image config title @src metadata full_text true)))
#(resizable-image config title @src metadata full_text true)))
(and (not db-based?) (contains? (common-config/text-formats) ext))
[:a.asset-ref.is-plaintext {:href (rfe/href :file {:path path})

View File

@@ -29,7 +29,6 @@
[frontend.state :as state]
[frontend.ui :as ui]
[frontend.util :as util]
[frontend.util.cursor :as cursor]
[frontend.version :refer [version]]
[goog.dom :as gdom]
[goog.object :as gobj]
@@ -45,22 +44,6 @@
[reitit.frontend.easy :as rfe]
[rum.core :as rum]))
(rum/defc recording-bar
[]
[:> react-draggable
{:onStart (fn [_event]
(when-let [pos (some-> (state/get-input) cursor/pos)]
(state/set-editor-last-pos! pos)))
:onStop (fn [_event]
(when-let [block (get @(get @state/state :editor/block) :block/uuid)]
(editor-handler/edit-block! block :max)
(when-let [input (state/get-input)]
(when-let [saved-cursor (state/get-editor-last-pos)]
(cursor/move-cursor-to input saved-cursor)))))}
[:div#audio-record-toolbar
{:style {:bottom (+ @util/keyboard-height 45)}}
(footer/audio-record-cp)]])
(rum/defc main <
{:did-mount (fn [state]
(when-let [element (gdom/getElement "main-content-container")]
@@ -79,7 +62,7 @@
(when-let [el (gdom/getElement "main-content-container")]
(dnd/unsubscribe! el :upload-files))
state)}
[{:keys [route-match margin-less-pages? route-name indexeddb-support? db-restoring? main-content show-recording-bar?]}]
[{:keys [route-match margin-less-pages? route-name indexeddb-support? db-restoring? main-content]}]
(let [left-sidebar-open? (state/sub :ui/left-sidebar-open?)
onboarding-and-home? (and (or (nil? (state/get-current-repo)) (config/demo-graph?))
(not config/publishing?)
@@ -103,9 +86,6 @@
:data-is-full-width (or margin-less-pages?
(contains? #{:all-files :all-pages :my-publishing} route-name))}
(when show-recording-bar?
(recording-bar))
(footer/footer)
(cond
@@ -453,7 +433,6 @@
logged? (user-handler/logged-in?)
fold-button-on-right? (state/enable-fold-button-right?)
show-action-bar? (state/sub :mobile/show-action-bar?)
show-recording-bar? (state/sub :mobile/show-recording-bar?)
preferred-language (state/sub [:preferred-language])]
(theme/container
{:t t
@@ -522,8 +501,7 @@
:light? light?
:db-restoring? db-restoring?
:main-content main-content'
:show-action-bar? show-action-bar?
:show-recording-bar? show-recording-bar?}))]
:show-action-bar? show-action-bar?}))]
(when window-controls?
(window-controls/container))

View File

@@ -1,15 +1,3 @@
#audio-record-toolbar {
position: fixed;
background-color: var(--ls-secondary-background-color);
width: 90px;
justify-content: left;
left: 5px;
transition: none;
z-index: 9999;
padding: 5px 5px 5px 8px;
border-radius: 5px;
}
.editor-inner {
@apply relative flex;
@@ -120,4 +108,4 @@ pre {
@apply opacity-100;
}
}
}
}

View File

@@ -349,15 +349,16 @@
[:svg.icon {:width size :height size :viewBox "0 0 24 24" :stroke "none" :fill "currentColor"}
[:path {:d "M11.14.028C7.315.36 4.072 2.263 1.98 5.411.487 7.646-.232 10.589.067 13.211c.32 2.772 1.4 5.124 3.242 7.049 4.643 4.852 12.252 5.001 17.038.343 1.085-1.057 1.738-1.959 2.407-3.303a11.943 11.943 0 0 0-2.429-13.925C18.372 1.495 16.015.388 13.27.078c-.68-.083-1.56-.1-2.13-.05zm4.814 2.567c1.112.437 2.086 1.068 3.032 1.986.62.598 1.323 1.46 1.3 1.599-.016.072-1.626.725-1.792.725-.056 0-.078-.072-.078-.25 0-.138-.011-.248-.028-.248-.01 0-.758.459-1.654 1.023-.897.565-1.666 1.024-1.71 1.024-.05 0-.133-.061-.194-.139-.127-.16-.216-.171-.354-.044-.066.056-.1.166-.1.316v.226l-.824.46c-.46.249-.89.453-.968.453h-.144V8.161c0-.863.016-2.025.038-2.573.034-.99.04-1.007.155-1.007.117 0 .128-.028.155-.514.067-1.107.25-1.284 1.362-1.323l.514-.016.16-.233c.156-.226.167-.226.366-.171.116.028.46.15.764.271zm-7.05.011l.122.183.641-.006c.604 0 .659.011.902.15.355.21.482.497.526 1.145l.033.498.172.016.171.017.017 2.716.011 2.722-.232.138a3.024 3.024 0 0 0-.936.875l-.177.27h-5.24v-.325l-.592-.017-.598-.017-.398-.586c-.332-.493-.454-.626-.758-.825-.415-.265-.404-.193-.139-1.023.659-2.025 2.203-3.945 4.1-5.107.67-.409 1.932-.995 2.159-1.001.055-.005.155.078.216.177zm12.163 4.902c.354.686.725 1.588.725 1.765 0 .071-.1.149-.327.26-.326.154-.393.237-.393.503 0 .155-.166.36-.564.692l-.327.27h-.99v.333h-2.767v-.886l-.332-.42c-.183-.227-.332-.432-.332-.454 0-.022 1.073-.68 2.39-1.46 2.17-1.29 2.402-1.417 2.485-1.34.05.045.244.377.432.737zm-5.556 3.087c.243.354.454.664.46.686.01.027-.394.05-.892.05h-.918l-.2-.332c-.11-.183-.193-.36-.182-.388.028-.083 1.167-.708 1.234-.68.033.011.254.31.498.664zm-7.282 2.567c.254.398.442.741.415.769-.111.1-5.163 3.32-5.213 3.32-.155 0-.813-1.317-1.024-2.048-.249-.863-.265-.769.188-1.045.178-.111.371-.321.637-.703l.387-.548.603-.027.609-.028.017-.21.016-.205H7.77l.459.725zm1.815-.476c.066.122.127.249.127.288 0 .077-.996.686-1.057.647-.05-.028-.714-1.1-.714-1.15 0-.023.343-.028.758-.023l.758.017.128.221zm9.158-.044l.016.21.554.028c.597.027.525 0 1.184.481.011.006.06.194.11.41.095.425.128.459.493.547.288.072.293.133.072.78-.57 1.682-1.787 3.425-3.287 4.686-.642.542-.603.542-.559-.055.045-.614-.027-.935-.254-1.162-.26-.255-.526-.221-1.3.177-.51.26-.698.332-.897.332-.327 0-.631-.094-.825-.255l-.16-.127.393-.36c.42-.381.62-.73.525-.907-.16-.298-.453-.37-1.045-.26-.498.1-.864.105-1.013.028-.188-.105-.288-.376-.26-.741.028-.332.022-.343-.216-.62l-.238-.282v-1.765l.393-.271c.216-.144.559-.448.758-.675l.37-.404h5.17l.017.205zm-7.814 2.157v.758l-.276.282-.277.283.083.238c.1.282.105.52.022.674-.1.194-.293.222-.896.133a8.212 8.212 0 0 0-.764-.083c-.68 0-.703.482-.06 1.256.31.37.31.365-.084.564-.553.277-.902.25-1.389-.116-.41-.304-.647-.393-.968-.36-.21.017-.31.061-.443.2l-.177.177.006.686c0 .382-.011.691-.023.691-.06 0-1.023-.846-1.45-1.272-.442-.448-.995-1.123-.995-1.217 0-.044 1.516-.72 1.615-.72.034 0 .045.084.034.194-.011.105-.006.194.01.194.017 0 1.362-.747 2.989-1.66a204.276 204.276 0 0 1 3.005-1.66c.022 0 .038.343.038.758z"}]]))
(def circle-stop
[:svg
{:width "20px"
:height "20px"
:viewBox "0 0 512 512"
:fill "currentColor"}
[:path
{:d
"M256 0C114.6 0 0 114.6 0 256c0 141.4 114.6 256 256 256s256-114.6 256-256C512 114.6 397.4 0 256 0zM352 328c0 13.2-10.8 24-24 24h-144C170.8 352 160 341.2 160 328v-144C160 170.8 170.8 160 184 160h144C341.2 160 352 170.8 352 184V328z"}]])
(comment
(def circle-stop
[:svg
{:width "20px"
:height "20px"
:viewBox "0 0 512 512"
:fill "currentColor"}
[:path
{:d
"M256 0C114.6 0 0 114.6 0 256c0 141.4 114.6 256 256 256s256-114.6 256-256C512 114.6 397.4 0 256 0zM352 328c0 13.2-10.8 24-24 24h-144C170.8 352 160 341.2 160 328v-144C160 170.8 170.8 160 184 160h144C341.2 160 352 170.8 352 184V328z"}]]))
;; Titlebar icons from https://github.com/microsoft/vscode-codicons
(defn window-minimize
@@ -384,3 +385,22 @@
([size]
[:svg.icon {:width size :height size :viewBox "0 0 16 16" :fill "currentColor"}
[:path {:fill-rule "evenodd" :clip-rule "evenodd" :d "M7.116 8l-4.558 4.558.884.884L8 8.884l4.558 4.558.884-.884L8.884 8l4.558-4.558-.884-.884L8 7.116 3.442 2.558l-.884.884L7.116 8z"}]]))
(defn audio-lines
([] (audio-lines 16))
([size]
[:svg.icon
{:stroke "currentColor",
:fill "none",
:stroke-linejoin "round",
:width size,
:height "24"
:stroke-linecap "round",
:stroke-width "2.5",
:viewBox "0 0 24 24"}
[:path {:d "M2 10v3"}]
[:path {:d "M6 6v11"}]
[:path {:d "M10 3v18"}]
[:path {:d "M14 8v7"}]
[:path {:d "M18 5v13"}]
[:path {:d "M22 10v3"}]]))

View File

@@ -30,8 +30,10 @@
(defn get-date-time-string
([]
(get-date-time-string (t/now)))
([date-time]
(tf/unparse custom-formatter date-time)))
([date-time & {:keys [formatter-str]}]
(tf/unparse (if formatter-str
(tf/formatter formatter-str)
custom-formatter) date-time)))
(defn get-locale-string
"Accepts a :date-time-no-ms string representation, or a cljs-time date object"

View File

@@ -912,17 +912,25 @@
block (first blocks)
block-parent (get uuid->dom-block (:block/uuid block))
sibling-block (when block-parent (util/get-prev-block-non-collapsed-non-embed block-parent))
blocks' (block-handler/get-top-level-blocks blocks)]
blocks' (block-handler/get-top-level-blocks blocks)
mobile? (util/capacitor-new?)]
(p/do!
(when (and sibling-block (not (util/capacitor-new?)))
(when (and sibling-block (not mobile?))
(let [{:keys [edit-block-f]} (move-to-prev-block repo sibling-block
(get block :block/format :markdown)
"")]
(state/set-state! :editor/edit-block-fn edit-block-f)))
(ui-outliner-tx/transact!
{:outliner-op :delete-blocks
:mobile-action-bar? mobile-action-bar?}
(outliner-op/delete-blocks! blocks' nil))))))
(let [journals (and mobile? (filter ldb/journal? blocks'))
blocks (remove (fn [b] (contains? (set (map :db/id journals)) (:db/id b))) blocks)]
(when (or (seq journals) (seq blocks))
(ui-outliner-tx/transact!
{:outliner-op :delete-blocks
:mobile-action-bar? mobile-action-bar?}
(when (seq blocks)
(outliner-op/delete-blocks! blocks nil))
(when (seq journals)
(doseq [journal journals]
(outliner-op/delete-page! (:block/uuid journal)))))))))))
(defn set-block-timestamp!
[block-id key value]
@@ -1493,7 +1501,7 @@
"Save incoming(pasted) assets to assets directory.
Returns: asset entity"
[repo files & {:keys [pdf-area?]}]
[repo files & {:keys [pdf-area? last-edit-block]}]
(p/let [[repo-dir asset-dir-rpath] (assets-handler/ensure-assets-dir! repo)]
(p/all
(for [[_index ^js file] (map-indexed vector files)]
@@ -1524,19 +1532,23 @@
:edit-block? false
:properties properties}
_ (db-based-save-asset! repo dir file file-rpath)
edit-block (state/get-edit-block)
edit-block (or (state/get-edit-block) last-edit-block)
today-page-name (date/today)
today-page-e (db-model/get-journal-page today-page-name)
today-page (if (nil? today-page-e)
(state/pub-event! [:page/create today-page-name])
today-page-e)
insert-to-current-block-page? (and (:block/uuid edit-block) (string/blank? (state/get-edit-content)) (not pdf-area?))
insert-opts' (if insert-to-current-block-page?
(assoc insert-opts
:block-uuid (:block/uuid edit-block)
:replace-empty-target? true
:sibling? true)
(assoc insert-opts :page (:block/uuid asset)))
result (api-insert-new-block! file-name-without-ext insert-opts')
new-entity (db/entity [:block/uuid (:block/uuid result)])]
(assoc insert-opts :page (:block/uuid today-page)))
new-block (api-insert-new-block! file-name-without-ext insert-opts')]
(when insert-to-current-block-page?
(state/clear-edit!))
(or new-entity
(or new-block
(throw (ex-info "Can't save asset" {:files files}))))))))))
(def insert-command! editor-common-handler/insert-command!)

View File

@@ -219,9 +219,6 @@
(defmethod handle :mobile/keyboard-will-show [[_ keyboard-height]]
(let [_main-node (util/app-scroll-container-node)]
(state/set-state! :mobile/show-action-bar? false)
(when (= (state/sub :editor/record-status) "RECORDING")
(state/set-state! :mobile/show-recording-bar? true))
(when-let [^js html (js/document.querySelector ":root")]
(.setProperty (.-style html) "--ls-native-kb-height" (str keyboard-height "px"))
(.add (.-classList html) "has-mobile-keyboard")
@@ -234,8 +231,6 @@
(defmethod handle :mobile/keyboard-will-hide [[_]]
(let [main-node (util/app-scroll-container-node)]
(when (= (state/sub :editor/record-status) "RECORDING")
(state/set-state! :mobile/show-recording-bar? false))
(when-let [^js html (js/document.querySelector ":root")]
(.removeProperty (.-style html) "--ls-native-kb-height")
(.setProperty (.-style html) "--ls-native-toolbar-opacity" 0)

View File

@@ -342,13 +342,13 @@
format (state/get-preferred-format repo)
db-based? (config/db-based-graph? repo)
create-f (fn []
(p/do!
(<create! title {:redirect? false
:split-namespace? false
:today-journal? true})
(when-not db-based? (state/pub-event! [:journal/insert-template today-page]))
(ui-handler/re-render-root!)
(plugin-handler/hook-plugin-app :today-journal-created {:title today-page})))]
(p/let [result (<create! title {:redirect? false
:split-namespace? false
:today-journal? true})]
(when-not db-based? (state/pub-event! [:journal/insert-template today-page]))
(ui-handler/re-render-root!)
(plugin-handler/hook-plugin-app :today-journal-created {:title today-page})
result))]
(when-not (db/get-page today-page)
(if db-based?
(create-f)

View File

@@ -1,9 +1,7 @@
(ns frontend.mobile.footer
(:require [clojure.string :as string]
[frontend.components.svg :as svg]
[frontend.date :as date]
[frontend.handler.editor :as editor-handler]
[frontend.mobile.record :as record]
[frontend.mobile.util :as mobile-util]
[frontend.state :as state]
[frontend.ui :as ui]
@@ -13,40 +11,9 @@
(rum/defc mobile-bar-command [command-handler icon]
[:button.bottom-action
{:on-pointer-down (fn [e]
(util/stop e)
(command-handler))}
(if (= icon "player-stop")
svg/circle-stop
(ui/icon icon {:size 24}))])
(defn seconds->minutes:seconds
[seconds]
(let [minutes (quot seconds 60)
seconds (mod seconds 60)]
(util/format "%02d:%02d" minutes seconds)))
(def *record-start (atom nil))
(rum/defcs audio-record-cp < rum/reactive
{:did-mount (fn [state]
(let [comp (:rum/react-component state)
callback #(rum/request-render comp)
interval (js/setInterval callback 1000)]
(assoc state ::interval interval)))
:will-mount (fn [state]
(js/clearInterval (::interval state))
(dissoc state ::interval))}
[state]
(if (= (state/sub :editor/record-status) "NONE")
(mobile-bar-command #(do (record/start-recording)
(reset! *record-start (js/Date.now))) "microphone")
[:div.flex.flex-row.items-center
(mobile-bar-command #(do (reset! *record-start nil)
(state/set-state! :mobile/show-recording-bar? false)
(record/stop-recording))
"player-stop")
[:div.timer.ml-2
{:on-click record/stop-recording}
(seconds->minutes:seconds (/ (- (js/Date.now) @*record-start) 1000))]]))
(util/stop e)
(command-handler))}
(ui/icon icon {:size 24})])
(rum/defc footer < rum/reactive
[]
@@ -55,7 +22,6 @@
(state/sub :mobile/show-tabbar?)
(state/get-current-repo))
[:div.cp__footer.w-full.bottom-0.justify-between
(audio-record-cp)
(mobile-bar-command
#(do (when-not (mobile-util/native-ipad?)
(state/set-left-sidebar-open! false))

View File

@@ -85,26 +85,13 @@
(defn- embed-asset-file [url _format]
(p/let [basename (node-path/basename url)
_label (-> basename util/node-path.name)
_path (assets-handler/get-asset-path basename)
time (date/get-current-time)
date-ref-name (date/today)
file (.readFile Filesystem #js {:path url})
file-base64-str (some-> file (.-data))
file (some-> file-base64-str (util/base64string-to-unit8array)
(vector) (clj->js) (js/File. basename #js {}))
asset-entity (editor-handler/db-based-save-assets!
(state/get-current-repo) [file] {})
asset-entity (some-> asset-entity (first))
url (util/format "[[%s]]" (:block/uuid asset-entity))
template (get-in (state/get-config)
[:quick-capture-templates :media]
"**{time}** [[quick capture]]: {url}")]
(-> template
(string/replace "{time}" time)
(string/replace "{date}" date-ref-name)
(string/replace "{text}" "")
(string/replace "{url}" (or url "")))))
(vector) (clj->js) (js/File. basename #js {}))
result (editor-handler/db-based-save-assets!
(state/get-current-repo) [file] {})]
(first result)))
(defn- embed-text-file
"Store external content with url into Logseq repo"
@@ -136,13 +123,8 @@
(defn- handle-received-media [result]
(p/let [{:keys [url]} result
page (or (state/get-current-page) (string/lower-case (date/journal-name)))
format (db/get-page-format page)
content (embed-asset-file url format)]
(if (state/get-edit-block)
(editor-handler/insert content)
(editor-handler/api-insert-new-block! content {:page page
:edit-block? false
:replace-empty-target? true}))))
format (db/get-page-format page)]
(embed-asset-file url format)))
(defn- handle-received-application [result]
(p/let [{:keys [title url type]} result
@@ -155,7 +137,9 @@
(contains? (set/union config/doc-formats config/media-formats)
(keyword application-type))
(embed-asset-file url format)
(do
(embed-asset-file url format)
nil)
:else
(notification/show!
@@ -165,11 +149,12 @@
:target "_blank"} "Github"]
". We will look into it soon."]
:warning false))]
(if (state/get-edit-block)
(editor-handler/insert content)
(editor-handler/api-insert-new-block! content {:page page
:edit-block? false
:replace-empty-target? true}))))
(when content
(if (state/get-edit-block)
(editor-handler/insert content)
(editor-handler/api-insert-new-block! content {:page page
:edit-block? false
:replace-empty-target? true})))))
(defn decode-received-result [m]
(into {} (for [[k v] m]
@@ -191,13 +176,13 @@
file (.readFile Filesystem #js {:path url})
file-base64-str (some-> file (.-data))
file (some-> file-base64-str (util/base64string-to-unit8array)
(vector) (clj->js) (js/File. basename #js {}))
asset-entity (editor-handler/db-based-save-assets!
(state/get-current-repo) [file] {})
asset-entity (some-> asset-entity (first))
(vector) (clj->js) (js/File. basename #js {}))
result (editor-handler/db-based-save-assets!
(state/get-current-repo) [file] {})
asset-entity (first result)
url-link (util/format "[[%s]]" (:block/uuid asset-entity))]
url-link)
(p/catch #(js/console.error "Error(handle asset file):" %))))
(p/catch #(js/console.error "Error(handle asset file):" %))))
(defn- handle-payload-resource
[{:keys [type name ext url] :as resource} format]
@@ -245,7 +230,7 @@
(handle-payload-resource resource format))
resources))
(p/then (partial string/join "\n")))]
(when (or (not-empty text) (not-empty rich-content))
(when (not-empty text)
(let [time (date/get-current-time)
date-ref-name (date/today)
content (-> template

View File

@@ -1,82 +0,0 @@
(ns frontend.mobile.record
(:require ["@capacitor/filesystem" :refer [Filesystem]]
["capacitor-voice-recorder" :refer [VoiceRecorder]]
[clojure.string :as string]
[frontend.date :as date]
[frontend.handler.assets :as assets-handler]
[frontend.handler.editor :as editor-handler]
[frontend.state :as state]
[frontend.util :as util]
[lambdaisland.glogi :as log]
[promesa.core :as p]))
(defn request-audio-recording-permission []
(p/then
(.requestAudioRecordingPermission VoiceRecorder)
(fn [^js result] (.-value result))))
(defn- has-audio-recording-permission? []
(p/then
(.hasAudioRecordingPermission VoiceRecorder)
(fn [^js result] (.-value result))))
(defn- set-recording-state []
(p/catch
(p/then (.getCurrentStatus VoiceRecorder)
(fn [^js result]
(let [{:keys [status]} (js->clj result :keywordize-keys true)]
(state/set-state! :editor/record-status status))))
(fn [error]
(js/console.error error))))
(defn start-recording []
(p/let [permission-granted? (has-audio-recording-permission?)
permission-granted? (or permission-granted?
(request-audio-recording-permission))]
(when permission-granted?
(p/catch
(p/then (.startRecording VoiceRecorder)
(fn [^js _result]
(set-recording-state)
(js/console.log "Start recording...")))
(fn [error]
(log/error :start-recording-error error))))))
(defn- embed-audio [database64]
(p/let [page (or (state/get-current-page) (string/lower-case (date/journal-name)))
filename (str (date/get-date-time-string-2) ".aac")
edit-block (state/get-edit-block)
format (get edit-block :block/format :markdown)
path (assets-handler/get-asset-path filename)
_file (p/catch
(.writeFile Filesystem (clj->js {:data database64
:path path
:recursive true}))
(fn [error]
(log/error :file/write-failed {:path path
:error error})))
url (util/format "../assets/%s" filename)
file-link (assets-handler/get-asset-file-link format url filename true)
args (merge (if (parse-uuid page)
{:block-uuid (uuid page)}
{:page page})
{:edit-block? false
:replace-empty-target? true})]
(if edit-block
(editor-handler/insert file-link)
(editor-handler/api-insert-new-block! file-link args))))
(defn stop-recording []
(p/catch
(p/then
(.stopRecording VoiceRecorder)
(fn [^js result]
(let [value (.-value result)
{:keys [_msDuration recordDataBase64 _mimeType]}
(js->clj value :keywordize-keys true)]
(set-recording-state)
(when (string? recordDataBase64)
(embed-audio recordDataBase64)
(js/console.log "Stop recording...")))))
(fn [error]
(js/console.error error))))

View File

@@ -177,9 +177,6 @@
;; Stores deleted refed blocks, indexed by repo
:editor/last-replace-ref-content-tx nil
;; for audio record
:editor/record-status "NONE"
:editor/code-block-context nil
:editor/latest-shortcut (atom nil)
@@ -225,7 +222,6 @@
;; mobile
:mobile/container-urls nil
:mobile/show-action-bar? false
:mobile/show-recording-bar? false
;; plugin
:plugin/enabled (and util/plugin-platform?
@@ -733,7 +729,9 @@ Similar to re-frame subscriptions"
([]
(enable-journals? (get-current-repo)))
([repo]
(not (false? (:feature/enable-journals? (sub-config repo))))))
(if (sqlite-util/db-based-graph? repo) ; db graphs rely on journals for quick capture/sharing/assets, etc.
true
(not (false? (:feature/enable-journals? (sub-config repo)))))))
(defn enable-flashcards?
([]

View File

@@ -87,6 +87,10 @@ html {
background: var(--ls-secondary-background-color);
}
.Card-content {
background: var(--ls-primary-background-color);
}
.BottomSheet-handle {
@apply bg-gray-03;
}
@@ -430,25 +434,23 @@ html[data-silk-native-page-scroll-replaced=false] .app-silk-index-scroll-view {
@apply flex border-t overflow-hidden select-none
bg-gray-02 absolute left-0 -bottom-0 w-full z-[1] dark:bg-gray-01;
padding-top: 6px;
padding-top: 4px;
padding-bottom: calc(env(safe-area-inset-bottom) + var(--silk-tabbar-bottom-paddding));
> .as-item {
@apply flex flex-1 flex-col items-center pb-1 transition-opacity;
@apply opacity-60 active:opacity-90;
&.active {
@apply text-accent-10 opacity-100;
> small {
@apply font-semibold;
}
}
@apply flex flex-1 flex-col items-center pb-1 transition-opacity opacity-60;
> small {
@apply text-[9px] -mt-2;
}
}
.as-item.active {
@apply opacity-90 text-accent-10;
small {
@apply font-semibold;
}
}
}
.app-silk-search-page {
@@ -534,6 +536,47 @@ html[data-silk-native-page-scroll-replaced=false] .app-silk-index-scroll-view {
overflow: hidden;
}
.app-audio-recorder-inner {
@apply relative pb-1;
h1 {
@apply pl-6 flex flex-col;
> small {
@apply opacity-40 text-sm;
}
&:after {
@apply content-[''] absolute top-[35px] left-[70px] bg-red-700
w-1.5 h-1.5 overflow-hidden rounded-full;
}
}
select {
@apply bg-transparent;
}
.record-ctrl-btn {
@apply w-12 h-12 text-green-800 bg-green-200 border-none;
&.recording {
@apply bg-red-200 border-red-500 text-red-700;
}
}
.timer-wrap {
@apply select-none;
> .timer {
@apply text-[28px] font-[500] font-mono opacity-90;
}
> small {
@apply opacity-50 -mt-1;
}
}
}
.left-sidebar-inner {
@apply -mx-4;

View File

@@ -1,6 +1,7 @@
(ns mobile.components.editor-toolbar
"Mobile editor toolbar"
(:require [frontend.commands :as commands]
[frontend.components.svg :as svg]
[frontend.handler.editor :as editor-handler]
[frontend.mobile.camera :as mobile-camera]
[frontend.mobile.haptics :as haptics]
@@ -10,7 +11,9 @@
[frontend.util.cursor :as cursor]
[goog.dom :as gdom]
[logseq.common.util.page-ref :as page-ref]
[mobile.init :as init]
[mobile.components.recorder :as recorder]
[mobile.init :as mobile-init]
[mobile.state :as mobile-state]
[promesa.core :as p]
[rum.core :as rum]))
@@ -42,7 +45,8 @@
(if event?
(command-handler e)
(command-handler)))}
(ui/icon icon {:size ui/icon-size :class class})]])
(if (string? icon)
(ui/icon icon {:size ui/icon-size :class class}) icon)]])
(defn- insert-text
[text opts]
@@ -76,7 +80,8 @@
(not (state/sub :editor/code-block-context))
(or (state/sub :editor/editing?)
(= "app-keep-keyboard-open-input" (some-> js/document.activeElement (.-id)))))
(let [commands' (commands)]
(let [commands' (commands)
quick-add? (mobile-state/quick-add-open?)]
[:div#mobile-editor-toolbar
{:on-click #(util/stop %)}
[:div.toolbar-commands
@@ -94,9 +99,14 @@
(for [command' commands']
command')
(command #(let [parent-id (state/get-edit-input-id)]
(mobile-camera/embed-photo parent-id)) {:icon "camera"} true)]
(mobile-camera/embed-photo parent-id)) {:icon "camera"} true)
(when-not quick-add?
(command (fn [] (recorder/record!)) {:icon (svg/audio-lines 20)}))]
[:div.toolbar-hide-keyboard
(command #(p/do!
(editor-handler/save-current-block!)
(state/clear-edit!)
(init/keyboard-hide)) {:icon "keyboard-show"})]])))
(if quick-add?
(command (fn [] (recorder/record!))
{:icon (svg/audio-lines 20)})
(command #(p/do!
(editor-handler/save-current-block!)
(state/clear-edit!)
(mobile-init/keyboard-hide)) {:icon "keyboard-show"}))]])))

View File

@@ -70,6 +70,7 @@
[]
(let [{:keys [open? content-fn opts]} (rum/react mobile-state/*popup-data)
quick-add? (= :ls-quick-add (:id opts))
audio-record? (= :ls-audio-record (:id opts))
action-sheet? (= :action-sheet (:type opts))
default-height (:default-height opts)]
@@ -94,7 +95,7 @@
(editor-handler/quick-add-open-last-block!)))
:onPresentAutoFocus #js {:focus false}}
(silkhq/bottom-sheet-backdrop
(when quick-add?
(when (or quick-add? audio-record?)
{:travelAnimation {:opacity (fn [data]
(let [progress (gobj/get data "progress")]
(js/Math.min (* progress 0.9) 0.9)))}}))

View File

@@ -0,0 +1,208 @@
(ns mobile.components.recorder
"Audio record"
(:require [cljs-time.core :as t]
[clojure.string :as string]
[frontend.date :as date]
[frontend.db.model :as db-model]
[frontend.handler.editor :as editor-handler]
[frontend.handler.notification :as notification]
[frontend.mobile.util :as mobile-util]
[frontend.state :as state]
[goog.functions :as gfun]
[logseq.shui.hooks :as hooks]
[logseq.shui.ui :as shui] ;; [mobile.speech :as speech]
[mobile.init :as init]
[mobile.state :as mobile-state]
[promesa.core :as p]
[rum.core :as rum]))
(defonce audio-file-format "MM-dd HH:mm")
(def *last-edit-block (atom nil))
(defn set-last-edit-block! [block] (reset! *last-edit-block block))
(defn ms-to-time-format [ms]
(let [total-seconds (quot ms 1000)
minutes (quot total-seconds 60)
seconds (mod total-seconds 60)]
(str (.padStart (str minutes) 2 "0") ":"
(.padStart (str seconds) 2 "0"))))
(defn save-asset-audio!
[blob]
(let [ext (some-> blob
(.-type)
(string/split ";")
(first)
(string/split "/")
(last))
ext (case ext
"mp4" "m4a"
ext)]
;; save local
(when-let [filename (some->> ext (str "Audio-"
(date/get-date-time-string (t/now)
{:formatter-str audio-file-format})
"."))]
(p/let [file (js/File. [blob] filename #js {:type (.-type blob)})
result (editor-handler/db-based-save-assets! (state/get-current-repo)
[file]
{:last-edit-block @*last-edit-block})
asset-entity (first result)]
(when asset-entity
(p/let [buffer-data (.arrayBuffer blob)
unit8-data (js/Uint8Array. buffer-data)]
(-> (.transcribeAudio2Text mobile-util/ui-local #js {:audioData (js/Array.from unit8-data)})
(p/then (fn [^js r]
(let [content (.-transcription r)]
(when-not (string/blank? content)
(editor-handler/api-insert-new-block! content
{:block-uuid (:block/uuid asset-entity)
:sibling? false
:replace-empty-target? true
:edit-block? false})))))
(p/catch #(js/console.error "Error(transcribeAudio2Text):" %)))))))))
(rum/defc ^:large-vars/cleanup-todo audio-recorder-aux
[]
(let [*wave-ref (rum/use-ref nil)
*micid-ref (rum/use-ref nil)
*timer-ref (rum/use-ref nil)
*save-ref (rum/use-ref false)
[^js wavesurfer set-wavesurfer!] (rum/use-state nil)
[^js recorder set-recorder!] (rum/use-state nil)
[mic-devices set-mic-devices!] (rum/use-state nil)
[_ set-status-pulse!] (rum/use-state 0)
recording? (some-> recorder (.isRecording))]
(hooks/use-effect!
(fn [] #(some-> wavesurfer (.destroy)))
[])
;; load mic devices
(hooks/use-effect!
(fn []
(when recorder
(-> js/window.WaveSurfer.Record
(.getAvailableAudioDevices)
(.then (fn [^js devices]
(let [*vs (volatile! [])]
(.forEach devices
(fn [^js device]
(vswap! *vs conj {:text (or (.-label device) (.-deviceId device))
:value (.-deviceId device)})))
(set-mic-devices! @*vs))))
(.catch (fn [^js err]
(js/console.error "ERR: load mic devices" err)))))
#())
[recorder])
(hooks/use-effect!
(fn []
(let [dark? (= "dark" (state/sub :ui/theme))
^js w (.create js/window.WaveSurfer
#js {:container (rum/deref *wave-ref)
:waveColor "rgb(167, 167, 167)"
:progressColor (if dark? "rgb(219, 216, 216)" "rgb(10, 10, 10)")
:barWidth 2
:barRadius 6})
^js r (.registerPlugin w
(.create js/window.WaveSurfer.Record
#js {:renderRecordedAudio false
:scrollingWaveform false
:continuousWaveform true
:mimeType "audio/mp4" ;; m4a
:audioBitsPerSecond 128000 ;; 128kbps适合 AAC-LC
:continuousWaveformDuration 30 ;; optional
}))]
(set-wavesurfer! w)
(set-recorder! r)
;; events
(let [handle-status-changed! (fn []
(set-status-pulse! (js/Date.now)))]
(doto r
(.on "record-end" (fn [^js blob]
(when (true? (rum/deref *save-ref))
(save-asset-audio! blob)
(rum/set-ref! *save-ref false)
(mobile-state/close-popup!))
(handle-status-changed!)))
(.on "record-progress" (gfun/throttle
(fn [time]
(try
(let [t (ms-to-time-format time)]
(set! (. (rum/deref *timer-ref) -textContent) t))
(catch js/Error e
(js/console.warn "WARN: bad progress time:" e))))
50))
(.on "record-start" handle-status-changed!)
(.on "record-pause" handle-status-changed!)
(.on "record-resume" handle-status-changed!))
;; auto start
(.startRecording r))
#()))
[])
[:div.app-audio-recorder-inner
[:h1.text-xl.p-6.relative
[:span.font-bold "REC"]
[:small (date/get-date-time-string (t/now) {:formatter-str audio-file-format})]]
[:div.px-6
[:div.flex.justify-between.items-center.hidden
[:span "&nbsp;"]
[:select.opacity-60
{:name "mic-select"
:style {:max-width "220px" :border "none"}
:ref *micid-ref}
(for [d mic-devices]
[:option {:value (:value d)}
(str "Mic: " (if (string/blank? (:text d)) "Default" (:text d)))])]]
[:div.wave.border.rounded {:ref *wave-ref}]]
[:div.p-6.flex.justify-between
(let [handle-record!
(fn []
(let [micid (some-> (rum/deref *micid-ref) (.-value))]
(-> (.startRecording recorder #js {:deviceId micid})
(.catch #(notification/show! (.-message %) :error)))))]
[:div.flex.justify-between.items-center.w-full
[:span.flex.flex-col.timer-wrap
[:strong.timer {:ref *timer-ref} "00:00"]
[:small "05:00"]]
(shui/button {:variant :outline
:class "record-ctrl-btn rounded-full recording"
:on-click (fn []
(if recording? ;; save audio
(do
(rum/set-ref! *save-ref true)
(.stopRecording recorder))
(handle-record!)))}
(shui/tabler-icon "player-stop" {:size 22}))])]]))
(defn- show-recorder
[]
(mobile-state/set-popup! {:open? true
:content-fn (fn [] (audio-recorder-aux))
:opts {:id :ls-audio-record}}))
(defn record!
[]
(let [editing-id (state/get-edit-input-id)
quick-add? (mobile-state/quick-add-open?)]
(set-last-edit-block! nil)
(if-not (string/blank? editing-id)
(p/do!
(editor-handler/save-current-block!)
(let [block (db-model/query-block-by-uuid (:block/uuid (state/get-edit-block)))]
(if quick-add?
(p/do!
(state/clear-edit!)
(init/keyboard-hide)
(show-recorder))
(do (set-last-edit-block! block)
(show-recorder)))))
(show-recorder))))

View File

@@ -72,10 +72,10 @@
[focused?])
(hooks/use-effect!
(fn []
(js/setTimeout #(some-> (rum/deref *ref) (.focus)) 32)
#())
[])
(fn []
(js/setTimeout #(some-> (rum/deref *ref) (.focus)) 32)
#())
[])
[:div.app-silk-search-page
[:div.hd
@@ -131,25 +131,29 @@
{:on-click #(set-input! item)}
item)])])
[:ul.px-3
{:class (when (and (not (string/blank? input))
(seq search-result))
"as-results")}
(for [{:keys [page? icon text header source-block]} result]
(let [block source-block]
[:li.flex.gap-1
{:on-click (fn []
(when-let [id (:block/uuid block)]
(p/let [block (db-async/<get-block (state/get-current-repo) id
{:children? false
:skip-transact? true
:skip-refresh? true})]
(when block (mobile-state/open-block-modal! block)))))}
[:div.flex.flex-col.gap-1.py-1
(when header
[:div.opacity-60.text-sm
header])
[:div.flex.flex-row.items-start.gap-1
(when (and page? icon) (ui/icon icon {:size 15
:class "text-muted-foreground mt-1"}))
[:div text]]]]))]]]))
(if (seq result)
[:ul.px-3
{:class (when (and (not (string/blank? input))
(seq search-result))
"as-results")}
(for [{:keys [page? icon text header source-block]} result]
(let [block source-block]
[:li.flex.gap-1
{:on-click (fn []
(when-let [id (:block/uuid block)]
(p/let [block (db-async/<get-block (state/get-current-repo) id
{:children? false
:skip-transact? true
:skip-refresh? true})]
(when block (mobile-state/open-block-modal! block)))))}
[:div.flex.flex-col.gap-1.py-1
(when header
[:div.opacity-60.text-sm
header])
[:div.flex.flex-row.items-start.gap-1
(when (and page? icon) (ui/icon icon {:size 15
:class "text-muted-foreground mt-1"}))
[:div text]]]]))]
(when-not (string/blank? input)
[:div.px-4.text-muted-foreground
"No results"]))]]))

View File

@@ -34,25 +34,25 @@
{:class (when (= current-tab "home") "active")
:data-tab "home"}
(shui/button {:variant :icon}
(shui/tabler-icon "home" {:size 24}))
(shui/tabler-icon "home" {:size 24}))
[:small "Journals"]]
[:span.as-item
{:class (when (= current-tab "search") "active")
:data-tab "search"}
(shui/button {:variant :icon}
(shui/tabler-icon "search" {:size 24}))
(shui/tabler-icon "search" {:size 24}))
[:small "Search"]]
[:span.as-item
(shui/button
{:variant :icon
:on-click (fn [^js e]
(util/stop e)
(editor-handler/show-quick-add))}
(shui/tabler-icon "plus" {:size 24}))
{:variant :icon
:on-click (fn [^js e]
(util/stop e)
(editor-handler/show-quick-add))}
(shui/tabler-icon "plus" {:size 24}))
[:small "Quick add"]]
[:span.as-item
{:class (when (= current-tab "settings") "active")
:data-tab "settings"}
(shui/button {:variant :icon}
(shui/tabler-icon "settings" {:size 24}))
(shui/tabler-icon "settings" {:size 24}))
[:small "Settings"]]]))

View File

@@ -33,6 +33,14 @@
[data]
(reset! *popup-data data))
(defn close-popup!
[]
(set-popup! nil))
(defn quick-add-open?
[]
(= :ls-quick-add (get-in @*popup-data [:opts :id])))
(defonce *left-sidebar-open? (atom false))
(defn open-left-sidebar!

View File

@@ -132,13 +132,6 @@
dependencies:
"@babel/types" "^7.28.0"
"@babel/runtime@7.11.2":
version "7.11.2"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.11.2.tgz#f549c13c754cc40b87644b9fa9f09a6a95fe0736"
integrity sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.10.2", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7":
version "7.28.2"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.2.tgz#2ae5a9d51cc583bd1f5673b3bb70d6d819682473"
@@ -338,16 +331,6 @@
resolved "https://registry.yarnpkg.com/@capacitor/status-bar/-/status-bar-7.0.1.tgz#6bd3769ef35158c961ff2a6b571c03e9bce55809"
integrity sha512-iDv3mXYo9CdxYRVwt3/pRyuk25p7Sn4GfaS/zMZyVIqTzsvKLCIIH3GdKK+ta+nsNcAVpCw/t5jFEBt1D18ctA==
"@capawesome/capacitor-background-task@7.0.1":
version "7.0.1"
resolved "https://registry.yarnpkg.com/@capawesome/capacitor-background-task/-/capacitor-background-task-7.0.1.tgz#5531717de4cea255156c7f83fd4bf0f1e472c534"
integrity sha512-ILkJ0bCOLperUc+fezzhpiH3Bfnr/318TI9XSrPU/vwvBXjMH7p7xYxKtjDA4VpJfbVh1cHmWLtRSWIk2wUglg==
"@capgo/capacitor-navigation-bar@7.1.2":
version "7.1.2"
resolved "https://registry.yarnpkg.com/@capgo/capacitor-navigation-bar/-/capacitor-navigation-bar-7.1.2.tgz#d017f22007e6e848c6a94aa38d70546b08d95473"
integrity sha512-lganepu29pay05+clCE41yEICE34xDzB61dmvtwWxZlWccvlu+XWbS8WnMSncvIotqBUmU1owfivG+usfrp4CA==
"@colors/colors@1.5.0":
version "1.5.0"
resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9"
@@ -2543,13 +2526,6 @@ canvas@^2.11.2:
nan "^2.17.0"
simple-get "^3.0.3"
capacitor-voice-recorder@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/capacitor-voice-recorder/-/capacitor-voice-recorder-5.0.0.tgz#ec8e421283de19063461838fd340d91f352c8875"
integrity sha512-rCZgbmdmj9eXlotziRnIXWoo+7/aGKM1dSeSrgaEmayu9aTs8xkwhpx9eeVe24VDC6sfMHTnwMl5311Ryr/yFA==
dependencies:
get-blob-duration "^1.2.0"
chalk@2.4.2, chalk@^2.4.1:
version "2.4.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
@@ -4688,13 +4664,6 @@ gensync@^1.0.0-beta.2:
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==
get-blob-duration@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/get-blob-duration/-/get-blob-duration-1.2.0.tgz#73cf7dac2fbaa219a5a03d5e5093e06e43814d49"
integrity sha512-2xNJa+oKznR21eC2ThMzw4a1931a3ogA8aHoY92xruZufc/02G7pl/P793GJZytkyI8xMJ2DepEQ7MWvg/tn/Q==
dependencies:
"@babel/runtime" "7.11.2"
get-caller-file@^1.0.1:
version "1.0.3"
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.3.tgz#f978fa4c90d1dfe7ff2d6beda2a515e713bdcf4a"
@@ -8652,11 +8621,6 @@ reflect.getprototypeof@^1.0.6, reflect.getprototypeof@^1.0.9:
get-proto "^1.0.1"
which-builtin-type "^1.2.1"
regenerator-runtime@^0.13.4:
version "0.13.11"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9"
integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==
regex-not@^1.0.0, regex-not@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c"
@@ -10815,6 +10779,11 @@ watchpack@^2.4.1:
glob-to-regexp "^0.4.1"
graceful-fs "^4.1.2"
wavesurfer.js@7.10.1:
version "7.10.1"
resolved "https://registry.yarnpkg.com/wavesurfer.js/-/wavesurfer.js-7.10.1.tgz#c2f799a05d939cbb1e5df8aa7e0485ab44ad7594"
integrity sha512-tF1ptFCAi8SAqKbM1e7705zouLC3z4ulXCg15kSP5dQ7VDV30Q3x/xFRcuVIYTT5+jB/PdkhiBRCfsMshZG1Ug==
webidl-conversions@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"