浏览代码

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 <[email protected]>
Charlie 2 月之前
父节点
当前提交
e103593c0d

+ 0 - 1
android/app/capacitor.build.gradle

@@ -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')
 

+ 1 - 0
android/app/src/main/AndroidManifest.xml

@@ -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"

+ 0 - 4
android/app/src/main/assets/capacitor.plugins.json

@@ -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"

+ 0 - 3
android/capacitor.settings.gradle

@@ -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')
 

+ 2 - 0
gulpfile.js

@@ -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',

+ 117 - 115
ios/App/App/Info.plist

@@ -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>

+ 75 - 1
ios/App/App/UILocalPlugin.swift

@@ -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
 

+ 0 - 1
ios/App/Podfile

@@ -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

+ 1 - 7
ios/App/Podfile.lock

@@ -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

+ 1 - 1
package.json

@@ -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": {

+ 2 - 0
resources/mobile/index.html

@@ -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>

+ 14 - 8
src/main/frontend/components/block.cljs

@@ -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})

+ 2 - 24
src/main/frontend/components/container.cljs

@@ -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))

+ 1 - 13
src/main/frontend/components/editor.css

@@ -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;
     }
   }
-}
+}

文件差异内容过多而无法显示
+ 0 - 0
src/main/frontend/components/svg.cljs


+ 4 - 2
src/main/frontend/date.cljs

@@ -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"

+ 24 - 12
src/main/frontend/handler/editor.cljs

@@ -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!)

+ 0 - 5
src/main/frontend/handler/events.cljs

@@ -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)

+ 7 - 7
src/main/frontend/handler/page.cljs

@@ -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)

+ 3 - 37
src/main/frontend/mobile/footer.cljs

@@ -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))

+ 21 - 36
src/main/frontend/mobile/intent.cljs

@@ -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

+ 0 - 82
src/main/frontend/mobile/record.cljs

@@ -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))))

+ 3 - 5
src/main/frontend/state.cljs

@@ -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?
   ([]

+ 54 - 11
src/main/mobile/components/app.css

@@ -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;
 

+ 18 - 8
src/main/mobile/components/editor_toolbar.cljs

@@ -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"}))]])))

+ 2 - 1
src/main/mobile/components/popup.cljs

@@ -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)))}}))

+ 208 - 0
src/main/mobile/components/recorder.cljs

@@ -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))))

+ 30 - 26
src/main/mobile/components/search.cljs

@@ -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"]))]]))

+ 8 - 8
src/main/mobile/components/ui_silk.cljs

@@ -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"]]]))

+ 8 - 0
src/main/mobile/state.cljs

@@ -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!

+ 5 - 36
yarn.lock

@@ -132,13 +132,6 @@
   dependencies:
     "@babel/types" "^7.28.0"
 
-"@babel/[email protected]":
-  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/[email protected]":
-  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/[email protected]":
-  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/[email protected]":
   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"
-
 [email protected], 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"
 
[email protected]:
+  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"

部分文件因为文件数量过多而无法显示