diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 9f32c244..793c2707 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -10,12 +10,16 @@ 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; 3A200C1527D64D7E00CBF02E /* AudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A200C1427D64D7E00CBF02E /* AudioPlayer.swift */; }; 3ABF580928059BAE005DFBE5 /* PlaybackSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ABF580828059BAE005DFBE5 /* PlaybackSession.swift */; }; + 3ABF580B2805A837005DFBE5 /* PlaybackReport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ABF580A2805A837005DFBE5 /* PlaybackReport.swift */; }; 3ABF618F2804325C0070250E /* PlayerHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ABF618E2804325C0070250E /* PlayerHandler.swift */; }; 3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AD4FCE428043E50006DB301 /* AbsDatabase.swift */; }; 3AD4FCE728043E72006DB301 /* AbsDatabase.m in Sources */ = {isa = PBXBuildFile; fileRef = 3AD4FCE628043E72006DB301 /* AbsDatabase.m */; }; 3AD4FCE928043FD7006DB301 /* ServerConnectionConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AD4FCE828043FD7006DB301 /* ServerConnectionConfig.swift */; }; 3AD4FCEB280443DD006DB301 /* Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AD4FCEA280443DD006DB301 /* Database.swift */; }; 3AD4FCED28044E6C006DB301 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AD4FCEC28044E6C006DB301 /* Store.swift */; }; + 3AF1970C2806E2590096F747 /* ApiClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AF1970B2806E2590096F747 /* ApiClient.swift */; }; + 3AF1970E2806E3CA0096F747 /* AbsAudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AF1970D2806E3CA0096F747 /* AbsAudioPlayer.swift */; }; + 3AF197102806E3DC0096F747 /* AbsAudioPlayer.m in Sources */ = {isa = PBXBuildFile; fileRef = 3AF1970F2806E3DC0096F747 /* AbsAudioPlayer.m */; }; 3AFCB5E827EA240D00ECCC05 /* NowPlayingInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AFCB5E727EA240D00ECCC05 /* NowPlayingInfo.swift */; }; 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; @@ -30,12 +34,16 @@ 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; 3A200C1427D64D7E00CBF02E /* AudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayer.swift; sourceTree = ""; }; 3ABF580828059BAE005DFBE5 /* PlaybackSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackSession.swift; sourceTree = ""; }; + 3ABF580A2805A837005DFBE5 /* PlaybackReport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackReport.swift; sourceTree = ""; }; 3ABF618E2804325C0070250E /* PlayerHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerHandler.swift; sourceTree = ""; }; 3AD4FCE428043E50006DB301 /* AbsDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsDatabase.swift; sourceTree = ""; }; 3AD4FCE628043E72006DB301 /* AbsDatabase.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AbsDatabase.m; sourceTree = ""; }; 3AD4FCE828043FD7006DB301 /* ServerConnectionConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConnectionConfig.swift; sourceTree = ""; }; 3AD4FCEA280443DD006DB301 /* Database.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database.swift; sourceTree = ""; }; 3AD4FCEC28044E6C006DB301 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = ""; }; + 3AF1970B2806E2590096F747 /* ApiClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiClient.swift; sourceTree = ""; }; + 3AF1970D2806E3CA0096F747 /* AbsAudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsAudioPlayer.swift; sourceTree = ""; }; + 3AF1970F2806E3DC0096F747 /* AbsAudioPlayer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AbsAudioPlayer.m; sourceTree = ""; }; 3AFCB5E727EA240D00ECCC05 /* NowPlayingInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlayingInfo.swift; sourceTree = ""; }; 4D8D412C26E187E400BA5F0D /* App-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "App-Bridging-Header.h"; sourceTree = ""; }; 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; @@ -95,6 +103,8 @@ children = ( 3AD4FCE428043E50006DB301 /* AbsDatabase.swift */, 3AD4FCE628043E72006DB301 /* AbsDatabase.m */, + 3AF1970D2806E3CA0096F747 /* AbsAudioPlayer.swift */, + 3AF1970F2806E3DC0096F747 /* AbsAudioPlayer.m */, ); path = plugins; sourceTree = ""; @@ -104,6 +114,7 @@ children = ( 3AD4FCE828043FD7006DB301 /* ServerConnectionConfig.swift */, 3ABF580828059BAE005DFBE5 /* PlaybackSession.swift */, + 3ABF580A2805A837005DFBE5 /* PlaybackReport.swift */, ); path = models; sourceTree = ""; @@ -114,6 +125,7 @@ 3AFCB5E727EA240D00ECCC05 /* NowPlayingInfo.swift */, 3AD4FCEA280443DD006DB301 /* Database.swift */, 3AD4FCEC28044E6C006DB301 /* Store.swift */, + 3AF1970B2806E2590096F747 /* ApiClient.swift */, ); path = util; sourceTree = ""; @@ -281,11 +293,15 @@ 3ABF580928059BAE005DFBE5 /* PlaybackSession.swift in Sources */, 3ABF618F2804325C0070250E /* PlayerHandler.swift in Sources */, 3AD4FCED28044E6C006DB301 /* Store.swift in Sources */, + 3ABF580B2805A837005DFBE5 /* PlaybackReport.swift in Sources */, + 3AF1970E2806E3CA0096F747 /* AbsAudioPlayer.swift in Sources */, 3AD4FCE928043FD7006DB301 /* ServerConnectionConfig.swift in Sources */, 3A200C1527D64D7E00CBF02E /* AudioPlayer.swift in Sources */, 3AFCB5E827EA240D00ECCC05 /* NowPlayingInfo.swift in Sources */, 3AD4FCEB280443DD006DB301 /* Database.swift in Sources */, 3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */, + 3AF197102806E3DC0096F747 /* AbsAudioPlayer.m in Sources */, + 3AF1970C2806E2590096F747 /* ApiClient.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/App/App/plugins/AbsAudioPlayer.m b/ios/App/App/plugins/AbsAudioPlayer.m new file mode 100644 index 00000000..653b0551 --- /dev/null +++ b/ios/App/App/plugins/AbsAudioPlayer.m @@ -0,0 +1,14 @@ +// +// AbsAudioPlayer.m +// App +// +// Created by Rasmus Krämer on 13.04.22. +// + +#import +#import + +CAP_PLUGIN(AbsAudioPlayer, "AbsAudioPlayer", + CAP_PLUGIN_METHOD(prepareLibraryItem, CAPPluginReturnPromise); + ) + diff --git a/ios/App/App/plugins/AbsAudioPlayer.swift b/ios/App/App/plugins/AbsAudioPlayer.swift new file mode 100644 index 00000000..e9dabebd --- /dev/null +++ b/ios/App/App/plugins/AbsAudioPlayer.swift @@ -0,0 +1,32 @@ +// +// AbsAudioPlayer.swift +// App +// +// Created by Rasmus Krämer on 13.04.22. +// + +import Foundation +import Capacitor + +@objc(AbsAudioPlayer) +public class AbsAudioPlayer: CAPPlugin { + @objc func prepareLibraryItem(_ call: CAPPluginCall) { + let libraryItemId = call.getString("libraryItemId") + let episodeId = call.getString("episodeId") + let playWhenReady = call.getBool("playWhenReady", true) + + if libraryItemId == nil { + NSLog("provide library item id") + return call.resolve() + } + if libraryItemId!.starts(with: "local") { + NSLog("local items are not implemnted") + return call.resolve() + } + + ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId) { session in + NSLog(OperationQueue.current) + PlayerHandler.startPlayback(session: session, playWhenReady: playWhenReady) + } + } +} diff --git a/ios/App/App/plugins/AbsDatabase.swift b/ios/App/App/plugins/AbsDatabase.swift index bfc49800..cdddd86c 100644 --- a/ios/App/App/plugins/AbsDatabase.swift +++ b/ios/App/App/plugins/AbsDatabase.swift @@ -28,47 +28,43 @@ extension String { @objc(AbsDatabase) public class AbsDatabase: CAPPlugin { @objc func setCurrentServerConnectionConfig(_ call: CAPPluginCall) { - Database.realmQueue.sync { - var id = call.getString("id") - let address = call.getString("address", "") - let userId = call.getString("userId", "") - let username = call.getString("username", "") - let token = call.getString("token", "") - - let name = "\(address) (\(username))" - let config = ServerConnectionConfig() - - if id == nil { - id = "\(address)@\(username)".toBase64() - } - - config.id = id! - config.name = name - config.address = address - config.userId = userId - config.username = username - config.token = token - - Store.serverConfig = config - call.resolve(convertServerConnectionConfigToJSON(config: config)) + var id = call.getString("id") + let address = call.getString("address", "") + let userId = call.getString("userId", "") + let username = call.getString("username", "") + let token = call.getString("token", "") + + let name = "\(address) (\(username))" + let config = ServerConnectionConfig() + + if id == nil { + id = "\(address)@\(username)".toBase64() } + + config.id = id! + config.name = name + config.address = address + config.userId = userId + config.username = username + config.token = token + + Store.serverConfig = config + call.resolve(convertServerConnectionConfigToJSON(config: config)) } @objc func getDeviceData(_ call: CAPPluginCall) { - Database.realmQueue.sync { - let configs = Database.getServerConnectionConfigs() - let index = Database.getActiveServerConfigIndex() - - call.resolve([ - "serverConnectionConfigs": configs.map { config in - return convertServerConnectionConfigToJSON(config: config) - }, - "lastServerConnectionConfigId": index < 0 ? -1 : configs.first(where: { - (config: ServerConnectionConfig) -> Bool in - return config.index == index - })!.id, - "currentLocalPlaybackSession": nil, // Luckily this isn't implemented yet - ]) - } + let configs = Database.getServerConnectionConfigs() + let index = Database.getActiveServerConfigIndex() + + call.resolve([ + "serverConnectionConfigs": configs.map { config in + return convertServerConnectionConfigToJSON(config: config) + }, + "lastServerConnectionConfigId": index < 0 ? -1 : configs.first(where: { + (config: ServerConnectionConfig) -> Bool in + return config.index == index + })!.id, + "currentLocalPlaybackSession": nil, // Luckily this isn't implemented yet + ]) } // We have to send a empty array or the client will break diff --git a/ios/App/Podfile b/ios/App/Podfile index 53d2981f..584acebb 100644 --- a/ios/App/Podfile +++ b/ios/App/Podfile @@ -23,4 +23,5 @@ target 'App' do # Add your Pods here pod 'RealmSwift', '~>10' + pod 'Alamofire', '~> 5.5' end diff --git a/ios/App/Shared/models/PlaybackReport.swift b/ios/App/Shared/models/PlaybackReport.swift new file mode 100644 index 00000000..f9c41ab0 --- /dev/null +++ b/ios/App/Shared/models/PlaybackReport.swift @@ -0,0 +1,13 @@ +// +// PlaybackReport.swift +// App +// +// Created by Rasmus Krämer on 12.04.22. +// + +import Foundation +import RealmSwift + +class PlaybackReport: Object { + @Persisted var token: String +} diff --git a/ios/App/Shared/models/PlaybackSession.swift b/ios/App/Shared/models/PlaybackSession.swift index 769cea4b..942b2a3d 100644 --- a/ios/App/Shared/models/PlaybackSession.swift +++ b/ios/App/Shared/models/PlaybackSession.swift @@ -7,7 +7,7 @@ import Foundation -struct PlaybackSession { +struct PlaybackSession: Decodable { var id: String var userId: String? var libraryItemId: String? @@ -20,8 +20,8 @@ struct PlaybackSession { var coverPath: String? var duration: Double var playMethod: Int - var startedAt: Double - var updatedAt: Double + var startedAt: Double? + var updatedAt: Double? var timeListening: Double var audioTracks: [AudioTrack] var currentTime: Double @@ -30,14 +30,14 @@ struct PlaybackSession { var serverConnectionConfigId: String? var serverAddress: String? } -struct Chapter { +struct Chapter: Decodable { var id: Int var start: Double var end: Double var title: String? } -struct AudioTrack { - var index: Int +struct AudioTrack: Decodable { + var index: Int? var startOffset: Double var duration: Double var title: String @@ -46,10 +46,10 @@ struct AudioTrack { var metadata: FileMetadata? // var isLocal: Bool // var localFileId: String? - // var audioProbeResult: AudioProbeResult? Need for local playback + // var audioProbeResult: AudioProbeResult? Needed for local playback var serverIndex: Int? } -struct FileMetadata { +struct FileMetadata: Decodable { var filename: String var ext: String var path: String diff --git a/ios/App/Shared/models/ServerConnectionConfig.swift b/ios/App/Shared/models/ServerConnectionConfig.swift index 85cf2a74..f016f309 100644 --- a/ios/App/Shared/models/ServerConnectionConfig.swift +++ b/ios/App/Shared/models/ServerConnectionConfig.swift @@ -19,13 +19,15 @@ class ServerConnectionConfig: Object { } func convertServerConnectionConfigToJSON(config: ServerConnectionConfig) -> Dictionary { - return [ - "id": config.id, - "name": config.name, - "index": config.index, - "address": config.address, - "userId": config.userId, - "username": config.username, - "token": config.token, - ] + return Database.realmQueue.sync { + return [ + "id": config.id, + "name": config.name, + "index": config.index, + "address": config.address, + "userId": config.userId, + "username": config.username, + "token": config.token, + ] + } } diff --git a/ios/App/Shared/player/PlayerHandler.swift b/ios/App/Shared/player/PlayerHandler.swift index 7e498bd1..46bd86b5 100644 --- a/ios/App/Shared/player/PlayerHandler.swift +++ b/ios/App/Shared/player/PlayerHandler.swift @@ -11,10 +11,14 @@ class PlayerHandler { private static var player: AudioPlayer? private static var session: PlaybackSession? - public static func startPlayback(session: PlaybackSession) { + public static func startPlayback(session: PlaybackSession, playWhenReady: Bool) { if player != nil { player?.destroy() player = nil } + + NowPlayingInfo.setSessionMetadata(metadata: NowPlayingMetadata(id: session.id, artworkUrl: "\(Store.serverConfig.address)/api/items/\(String(describing: session.libraryItemId))/cover?token=\(Store.serverConfig.token)", title: session.displayTitle ?? "Unknown title", author: session.displayAuthor, series: nil)) + self.session = session + player = AudioPlayer(playbackSession: session, playWhenReady: playWhenReady) } } diff --git a/ios/App/Shared/util/ApiClient.swift b/ios/App/Shared/util/ApiClient.swift new file mode 100644 index 00000000..37faf6a9 --- /dev/null +++ b/ios/App/Shared/util/ApiClient.swift @@ -0,0 +1,52 @@ +// +// ApiClient.swift +// App +// +// Created by Rasmus Krämer on 13.04.22. +// + +import Foundation +import Alamofire + +class ApiClient { + /* + public static func getResource(endpoint: String, decodable: T.Type = T.self, callback: ((_ param: DataRequest) -> Void)?) { + let headers: HTTPHeaders = [ + "Authorization": "Bearer \(Store.serverConfig.token)" + ] + + AF.request("\(Store.serverConfig.address)/\(endpoint)", headers: headers).responseDecodable(of: decodable) { response in + // callback(response) + debugPrint("Response: \(response)") + } + } + */ + + public static func postResource(endpoint: String, parameters: [String: String], decodable: T.Type = T.self, callback: ((_ param: T) -> Void)?) { + let headers: HTTPHeaders = [ + "Authorization": "Bearer \(Store.serverConfig.token)" + ] + + AF.request("\(Store.serverConfig.address)/\(endpoint)", method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers).responseDecodable(of: decodable) { response in + switch response.result { + case .success(let obj): + callback?(obj) + case .failure(let error): + NSLog("api request to \(endpoint) failed") + print(error) + } + } + } + + public static func startPlaybackSession(libraryItemId: String, episodeId: String?, callback: @escaping (_ param: PlaybackSession) -> Void) { + var endpoint = "api/items/\(libraryItemId)/play" + if episodeId != nil { + endpoint += "/\(episodeId!)" + } + + ApiClient.postResource(endpoint: endpoint, parameters: [ + "forceTranscode": "true", // TODO: direct play + "mediaPlayer": "AVPlayer", + ], decodable: PlaybackSession.self, callback: callback) + } +} diff --git a/ios/App/Shared/util/Database.swift b/ios/App/Shared/util/Database.swift index a81c5134..ae68e6c6 100644 --- a/ios/App/Shared/util/Database.swift +++ b/ios/App/Shared/util/Database.swift @@ -8,6 +8,8 @@ import Foundation import RealmSwift +extension String: Error {} + class Database { public static let realmQueue = DispatchQueue(label: "realm-queue") @@ -18,28 +20,64 @@ class Database { }() public static func getActiveServerConfigIndex() -> Int { - guard let config = instance.objects(ServerConnectionConfig.self).first else { - return -1 + return realmQueue.sync { + guard let config = instance.objects(ServerConnectionConfig.self).first else { + return -1 + } + return config.index } - return config.index } public static func setServerConnectionConfig(config: ServerConnectionConfig) { - let existing: ServerConnectionConfig? = instance.object(ofType: ServerConnectionConfig.self, forPrimaryKey: config.id) + var refrence: ThreadSafeReference? + if config.realm != nil { + refrence = ThreadSafeReference(to: config) + } - try! instance.write { - if existing != nil { - instance.delete(existing!) + realmQueue.sync { + let existing: ServerConnectionConfig? = instance.object(ofType: ServerConnectionConfig.self, forPrimaryKey: config.id) + + do { + try instance.write { + if existing != nil { + instance.delete(existing!) + } + if refrence == nil { + instance.add(config) + } else { + guard let resolved = instance.resolve(refrence!) else { + throw "unable to resolve refrence" + } + + instance.add(resolved); + } + } + } catch(let exception) { + NSLog("failed to save server config") + debugPrint(exception) } - instance.add(config) } } public static func getServerConnectionConfigs() -> [ServerConnectionConfig] { - let configs = instance.objects(ServerConnectionConfig.self) + var refrences: [ThreadSafeReference] = [] - if configs.count <= 0 { + realmQueue.sync { + let configs = instance.objects(ServerConnectionConfig.self) + refrences = configs.map { config in + return ThreadSafeReference(to: config) + } + } + + do { + let realm = try Realm() + + return refrences.map { refrence in + return realm.resolve(refrence)! + } + } catch(let exception) { + NSLog("error while readling configs") + debugPrint(exception) return [] } - return Array(configs) } } diff --git a/ios/App/Shared/util/NowPlayingInfo.swift b/ios/App/Shared/util/NowPlayingInfo.swift index cd6f2e3b..d04bb68b 100644 --- a/ios/App/Shared/util/NowPlayingInfo.swift +++ b/ios/App/Shared/util/NowPlayingInfo.swift @@ -27,7 +27,7 @@ struct NowPlayingMetadata { class NowPlayingInfo { private static var nowPlayingInfo: [String: Any] = [:] - public static func setAudiobook(metadata: NowPlayingMetadata) { + public static func setSessionMetadata(metadata: NowPlayingMetadata) { setMetadata(artwork: nil, metadata: nil) if !shouldFetchCover(id: metadata.id) || metadata.artworkUrl == nil { diff --git a/ios/App/Shared/util/Store.swift b/ios/App/Shared/util/Store.swift index f7ab787c..d4764f0f 100644 --- a/ios/App/Shared/util/Store.swift +++ b/ios/App/Shared/util/Store.swift @@ -9,7 +9,7 @@ import Foundation import RealmSwift class Store { - private static var _serverConfig: ServerConnectionConfig? + @ThreadSafe private static var _serverConfig: ServerConnectionConfig? // ONLY USE REALM IN Database.realmQueue OR ELSE THE APP WILL CRASH public static var serverConfig: ServerConnectionConfig { get { @@ -25,7 +25,7 @@ class Store { } set(updated) { Database.setServerConnectionConfig(config: updated) - _serverConfig = updated + _serverConfig = nil } } } diff --git a/package-lock.json b/package-lock.json index b1a04c5f..58f234e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,8 @@ "libarchive.js": "^1.3.0", "nuxt": "^2.15.7", "socket.io-client": "^4.1.3", - "vue-toastification": "^1.7.11" + "vue-toastification": "^1.7.11", + "vuedraggable": "^2.24.3" }, "devDependencies": { "@babel/core": "7.13.15", @@ -15482,6 +15483,11 @@ "node": ">=0.10.0" } }, + "node_modules/sortablejs": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.10.2.tgz", + "integrity": "sha512-YkPGufevysvfwn5rfdlGyrGjt7/CRHwvRPogD/lC+TnvcN29jDpCifKP+rBqf+LRldfXSTh+0CGLcSg0VIxq3A==" + }, "node_modules/source-list-map": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", @@ -17325,6 +17331,14 @@ "vue": "^2.0.0" } }, + "node_modules/vuedraggable": { + "version": "2.24.3", + "resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-2.24.3.tgz", + "integrity": "sha512-6/HDXi92GzB+Hcs9fC6PAAozK1RLt1ewPTLjK0anTYguXLAeySDmcnqE8IC0xa7shvSzRjQXq3/+dsZ7ETGF3g==", + "dependencies": { + "sortablejs": "1.10.2" + } + }, "node_modules/vuex": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/vuex/-/vuex-3.6.2.tgz",