├── .gitignore ├── LICENSE ├── README.md ├── fonts ├── OFL.txt └── Oxanium-VariableFont_wght.ttf ├── localwave.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ ├── xcshareddata │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcuserdata │ │ └── snowbear.xcuserdatad │ │ └── UserInterfaceState.xcuserstate ├── xcshareddata │ └── xcschemes │ │ └── musicapp.xcscheme └── xcuserdata │ └── snowbear.xcuserdatad │ ├── xcdebugger │ └── Breakpoints_v2.xcbkptlist │ └── xcschemes │ └── xcschememanagement.plist ├── localwave.xctestplan ├── localwave ├── App.swift ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json ├── Info.plist ├── Preview Content │ └── .gitkeep ├── Sources │ ├── App │ │ ├── DependencyContainer.swift │ │ └── MusicApp.swift │ ├── Core │ │ └── Utils.swift │ ├── Data │ │ ├── Providers │ │ │ └── DefaultICloudProvider.swift │ │ ├── Repositories │ │ │ ├── SQLitePlaylistRepository.swift │ │ │ ├── SQLitePlaylistSongRepository.swift │ │ │ ├── SQLiteSongRepository.swift │ │ │ ├── SQLiteSourcePathRepository.swift │ │ │ ├── SQLiteSourcePathSearchRepository.swift │ │ │ ├── SQLiteSourceRepository.swift │ │ │ └── SQLiteUserRepository.swift │ │ └── Services │ │ │ ├── BackgroundFileService.swift │ │ │ ├── DefaultPlayerPersistenceService.swift │ │ │ ├── DefaultSongImportService.swift │ │ │ ├── DefaultSourceImportService.swift │ │ │ ├── DefaultSourceService.swift │ │ │ ├── DefaultSourceSyncService.swift │ │ │ ├── DefaultUserCloudService.swift │ │ │ └── DefaultUserService.swift │ ├── Domain │ │ ├── Models │ │ │ └── Models.swift │ │ └── Protocols │ │ │ └── Protocols.swift │ └── Features │ │ ├── Common │ │ ├── AppDelegate.swift │ │ ├── ErrorView.swift │ │ ├── ThemeProvider.swift │ │ └── coverArt.swift │ │ ├── Library │ │ ├── ViewModels │ │ │ ├── AlbumListViewModel.swift │ │ │ ├── ArtistListViewModel.swift │ │ │ └── SongListViewModel.swift │ │ └── Views │ │ │ ├── AlbumCell.swift │ │ │ ├── AlbumGridView.swift │ │ │ ├── AlbumSongListView.swift │ │ │ ├── ArtistListView.swift │ │ │ ├── ArtistSongListView.swift │ │ │ ├── LibraryNavigation.swift │ │ │ ├── SongListView.swift │ │ │ ├── SongMetadataEditorView.swift │ │ │ └── SongRow.swift │ │ ├── Player │ │ ├── ViewModels │ │ │ └── PlayerViewModel.swift │ │ └── Views │ │ │ ├── MiniPlayerView.swift │ │ │ ├── MiniPlayerViewInner.swift │ │ │ └── PlayerView.swift │ │ ├── Playlists │ │ ├── ViewModels │ │ │ ├── PlaylistDetailViewModel.swift │ │ │ └── PlaylistListViewModel.swift │ │ └── Views │ │ │ ├── PlaylistDetailView.swift │ │ │ ├── PlaylistListView.swift │ │ │ ├── PlaylistSelectionView.swift │ │ │ ├── SelectableSongRow.swift │ │ │ └── SongSelectionView.swift │ │ ├── Shared │ │ ├── CustomTabView.swift │ │ ├── MainTabView.swift │ │ ├── SearchBar.swift │ │ ├── TabItem.swift │ │ ├── TabItemBuilder.swift │ │ ├── TabState.swift │ │ └── TabViewBuilder.swift │ │ └── Sync │ │ ├── ViewModels │ │ └── SourceBrowseViewModel.swift │ │ └── Views │ │ ├── SourceBrowseView.swift │ │ ├── SourceBrowseViewInternal.swift │ │ ├── SourceGridCell.swift │ │ ├── SyncView.swift │ │ └── SyncViewState.swift └── localwave.entitlements └── localwaveTests └── Tests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files 2 | 3 | # dependencies 4 | node_modules/ 5 | 6 | # Expo 7 | .expo/ 8 | dist/ 9 | web-build/ 10 | 11 | # Native 12 | *.orig.* 13 | *.jks 14 | *.p8 15 | *.p12 16 | *.key 17 | *.mobileprovision 18 | 19 | # Metro 20 | .metro-health-check* 21 | 22 | # debug 23 | npm-debug.* 24 | yarn-debug.* 25 | yarn-error.* 26 | 27 | # macOS 28 | .DS_Store 29 | *.pem 30 | 31 | # local env files 32 | .env*.local 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | 37 | # @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb 38 | # The following patterns were generated by expo-cli 39 | 40 | expo-env.d.ts 41 | # @end expo-cli 42 | buildServer.json 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Oleg Pustovit 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LocalWave 2 | 3 | ![Screenshots](https://nexo.sh/posts/why-i-built-a-native-mp3-player-in-swiftui/frame_source.webp) 4 | 5 | LocalWave is an **offline-first** music player for iOS that enables full control of your personal MP3 library without relying on Apple Music or iTunes Match. Built with SwiftUI and structured using a layered MVVM + Actor-based architecture, LocalWave prioritizes offline use and searchability. It was designed out of frustration with Apple's closed ecosystem and lack of decent support for self-hosted MP3 libraries. [Link to the article](https://nexo.sh/posts/why-i-built-a-native-mp3-player-in-swiftui/) 6 | 7 | ## Why LocalWave Exists 8 | 9 | In 2025, Apple still restricts basic MP3 playback unless you pay for services like Apple Music or iTunes Match. LocalWave was built from scratch as a personal response to these limitations. It allows users to: 10 | 11 | - Import MP3 files from iCloud or Files app using persistent bookmarks 12 | - Build and search their own curated music libraries 13 | - Avoid subscriptions or cloud lock-in 14 | - Leverage native performance and Swift concurrency for a smooth user experience 15 | 16 | ## Features 17 | 18 | ### 🎵 Music Library Management 19 | 20 | - **Artists / Albums / Songs Views**: Browse, sort, and search with artwork and metadata 21 | - **Full-Text Search (FTS5)**: Search across title, artist, album, and path with SQLite-powered fuzzy matching 22 | - **Playlists**: Create custom playlists with drag-and-drop reordering 23 | - **Library Sync**: Import music folders recursively from iCloud using background sync services 24 | 25 | ### 🎧 Advanced Playback 26 | 27 | - **AVFoundation-Based Audio Playback**: Full support for MP3s with lock screen controls 28 | - **Mini and Full Player UI**: Seamless transitions and persistent playback 29 | - **Queue Management**: Shuffle, repeat, and reorder tracks 30 | - **Background Playback**: Continues playing while the app is backgrounded 31 | 32 | ### 📁 Filesystem and iCloud Sync 33 | 34 | - **Persistent File Access via Security-Scoped Bookmarks**: Stores references safely in SQLite 35 | - **Fallback File Copying**: Copies MP3s into app container while bookmarks are still valid 36 | - **Multi-source Import**: Add and merge multiple folder trees into a unified library 37 | 38 | ### 🔍 Full-Text Search Engine 39 | 40 | - **Powered by SQLite FTS5**: Fast and lightweight search without any cloud dependencies 41 | - **BM25 Ranking**: Smart search results prioritization 42 | - **Async Upserts and Transaction Handling**: Keeps search indexes reliable and performant 43 | 44 | ### 🧠 Architecture Highlights 45 | 46 | - **Swift Actors**: State-safe, concurrency-friendly domain logic 47 | - **MVVM Layering**: Clear separation between View, ViewModel, Repository, and Domain layers 48 | - **SQLite with FTS5**: Used instead of CoreData for tighter schema and query control 49 | 50 | ## Requirements 51 | 52 | - iOS 15.0+ 53 | - Xcode 13.0+ 54 | - Swift 5.5+ 55 | 56 | ## Installation 57 | 58 | 1. Clone the repository: 59 | 60 | ```bash 61 | git clone https://github.com/nexo-tech/localwave.git 62 | ``` 63 | 64 | 2. Open the project in Xcode: 65 | 66 | ```bash 67 | cd localwave 68 | open localwave.xcodeproj 69 | ``` 70 | 71 | 3. Build and run the project (⌘R) 72 | 73 | ## Architecture 74 | 75 | LocalWave follows a clean-layered MVVM architecture with a backend-style separation of logic: 76 | 77 | - **Models**: Core types for songs, albums, metadata, and state 78 | - **Repositories**: Async interfaces over SQLite using raw SQL and SQLite.swift 79 | - **Actors**: Swift actors encapsulate business rules (search, import, playback queue) 80 | - **ViewModels**: Subscribe to actors and provide bindable UI state 81 | - **Views**: SwiftUI-based UI rendering from ViewModel output 82 | - **Services**: Playback, file access, metadata parsing, remote control handling 83 | 84 | ### Directory Structure 85 | 86 | ``` 87 | localwave/ 88 | ├── Sources/ 89 | │ ├── Features/ 90 | │ │ ├── Common/ 91 | │ │ ├── Library/ 92 | │ │ ├── Player/ 93 | │ │ └── Sync/ 94 | │ ├── Models/ 95 | │ ├── Repositories/ 96 | │ └── Services/ 97 | └── Tests/ 98 | ``` 99 | 100 | ### Data Model and FTS Tables 101 | 102 | | Domain | Actor / Repo | FTS Table | Indexed Columns | 103 | | ----------------- | ---------------------------------- | ------------------ | ----------------------------------------- | 104 | | Library Songs | `SQLiteSongRepository` | `songs_fts` | `artist`, `title`, `album`, `albumArtist` | 105 | | File Import Paths | `SQLiteSourcePathSearchRepository` | `source_paths_fts` | `fullPath`, `fileName` | 106 | 107 | ## Contributing 108 | 109 | 1. Fork the repository 110 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 111 | 3. Commit your changes (`git commit -m 'Add some amazing feature'`) 112 | 4. Push to the branch (`git push origin feature/amazing-feature`) 113 | 5. Open a Pull Request 114 | 115 | ## License 116 | 117 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 118 | 119 | ## Acknowledgments 120 | 121 | - Apple Developer Documentation (AVFoundation, SwiftUI, Combine) 122 | - `SQLite` and [`SQLite.swift`](https://github.com/stephencelis/SQLite.swift) 123 | - `AVAudioPlayer` and `MPRemoteCommandCenter` 124 | - GitHub contributors for open-source ID3 parsing examples 125 | 126 | ## Contact 127 | 128 | Oleg Pustovit - [@nexo_v1](https://twitter.com/nexo_v1) 129 | 130 | Project Link: [https://github.com/nexo-tech/localwave](https://github.com/nexo-tech/localwave) 131 | -------------------------------------------------------------------------------- /fonts/OFL.txt: -------------------------------------------------------------------------------- 1 | Copyright 2019 The Oxanium Project Authors (https://github.com/sevmeyer/oxanium) 2 | 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 4 | This license is copied below, and is also available with a FAQ at: 5 | https://openfontlicense.org 6 | 7 | 8 | ----------------------------------------------------------- 9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 10 | ----------------------------------------------------------- 11 | 12 | PREAMBLE 13 | The goals of the Open Font License (OFL) are to stimulate worldwide 14 | development of collaborative font projects, to support the font creation 15 | efforts of academic and linguistic communities, and to provide a free and 16 | open framework in which fonts may be shared and improved in partnership 17 | with others. 18 | 19 | The OFL allows the licensed fonts to be used, studied, modified and 20 | redistributed freely as long as they are not sold by themselves. The 21 | fonts, including any derivative works, can be bundled, embedded, 22 | redistributed and/or sold with any software provided that any reserved 23 | names are not used by derivative works. The fonts and derivatives, 24 | however, cannot be released under any other type of license. The 25 | requirement for fonts to remain under this license does not apply 26 | to any document created using the fonts or their derivatives. 27 | 28 | DEFINITIONS 29 | "Font Software" refers to the set of files released by the Copyright 30 | Holder(s) under this license and clearly marked as such. This may 31 | include source files, build scripts and documentation. 32 | 33 | "Reserved Font Name" refers to any names specified as such after the 34 | copyright statement(s). 35 | 36 | "Original Version" refers to the collection of Font Software components as 37 | distributed by the Copyright Holder(s). 38 | 39 | "Modified Version" refers to any derivative made by adding to, deleting, 40 | or substituting -- in part or in whole -- any of the components of the 41 | Original Version, by changing formats or by porting the Font Software to a 42 | new environment. 43 | 44 | "Author" refers to any designer, engineer, programmer, technical 45 | writer or other person who contributed to the Font Software. 46 | 47 | PERMISSION & CONDITIONS 48 | Permission is hereby granted, free of charge, to any person obtaining 49 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 50 | redistribute, and sell modified and unmodified copies of the Font 51 | Software, subject to the following conditions: 52 | 53 | 1) Neither the Font Software nor any of its individual components, 54 | in Original or Modified Versions, may be sold by itself. 55 | 56 | 2) Original or Modified Versions of the Font Software may be bundled, 57 | redistributed and/or sold with any software, provided that each copy 58 | contains the above copyright notice and this license. These can be 59 | included either as stand-alone text files, human-readable headers or 60 | in the appropriate machine-readable metadata fields within text or 61 | binary files as long as those fields can be easily viewed by the user. 62 | 63 | 3) No Modified Version of the Font Software may use the Reserved Font 64 | Name(s) unless explicit written permission is granted by the corresponding 65 | Copyright Holder. This restriction only applies to the primary font name as 66 | presented to the users. 67 | 68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 69 | Software shall not be used to promote, endorse or advertise any 70 | Modified Version, except to acknowledge the contribution(s) of the 71 | Copyright Holder(s) and the Author(s) or with their explicit written 72 | permission. 73 | 74 | 5) The Font Software, modified or unmodified, in part or in whole, 75 | must be distributed entirely under this license, and must not be 76 | distributed under any other license. The requirement for fonts to 77 | remain under this license does not apply to any document created 78 | using the Font Software. 79 | 80 | TERMINATION 81 | This license becomes null and void if any of the above conditions are 82 | not met. 83 | 84 | DISCLAIMER 85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 93 | OTHER DEALINGS IN THE FONT SOFTWARE. 94 | -------------------------------------------------------------------------------- /fonts/Oxanium-VariableFont_wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nexo-tech/localwave/29704bcccd8df94b62a1c3c20d8d12474eae1d32/fonts/Oxanium-VariableFont_wght.ttf -------------------------------------------------------------------------------- /localwave.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /localwave.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "42598adf0e546dd7490b40b2fd0f5298dee9c098f71dcdbfcb5cc4c07573f374", 3 | "pins" : [ 4 | { 5 | "identity" : "sqlite.swift", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/stephencelis/SQLite.swift", 8 | "state" : { 9 | "revision" : "a95fc6df17d108bd99210db5e8a9bac90fe984b8", 10 | "version" : "0.15.3" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /localwave.xcodeproj/project.xcworkspace/xcuserdata/snowbear.xcuserdatad/UserInterfaceState.xcuserstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nexo-tech/localwave/29704bcccd8df94b62a1c3c20d8d12474eae1d32/localwave.xcodeproj/project.xcworkspace/xcuserdata/snowbear.xcuserdatad/UserInterfaceState.xcuserstate -------------------------------------------------------------------------------- /localwave.xcodeproj/xcshareddata/xcschemes/musicapp.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 31 | 32 | 35 | 36 | 37 | 38 | 41 | 47 | 48 | 49 | 52 | 58 | 59 | 60 | 61 | 62 | 72 | 74 | 80 | 81 | 82 | 83 | 89 | 91 | 97 | 98 | 99 | 100 | 102 | 103 | 106 | 107 | 108 | -------------------------------------------------------------------------------- /localwave.xcodeproj/xcuserdata/snowbear.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | SQLite (Playground).xcscheme 8 | 9 | orderHint 10 | 1 11 | 12 | localwave.xcscheme_^#shared#^_ 13 | 14 | orderHint 15 | 0 16 | 17 | 18 | SuppressBuildableAutocreation 19 | 20 | 10811E6E2D31CF01008CAD75 21 | 22 | primary 23 | 24 | 25 | 10811E7E2D31CF02008CAD75 26 | 27 | primary 28 | 29 | 30 | 10811E882D31CF02008CAD75 31 | 32 | primary 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /localwave.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "4A0AD530-0D4B-49E9-9F01-4E9A72F904B8", 5 | "name" : "Test Scheme Action", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "targetForVariableExpansion" : { 13 | "containerPath" : "container:localwave.xcodeproj", 14 | "identifier" : "10811E6E2D31CF01008CAD75", 15 | "name" : "localwave" 16 | } 17 | }, 18 | "testTargets" : [ 19 | { 20 | "parallelizable" : false, 21 | "target" : { 22 | "containerPath" : "container:localwave.xcodeproj", 23 | "identifier" : "10811E7E2D31CF02008CAD75", 24 | "name" : "musicappTests" 25 | } 26 | }, 27 | { 28 | "parallelizable" : true, 29 | "target" : { 30 | "containerPath" : "container:localwave.xcodeproj", 31 | "identifier" : "10811E882D31CF02008CAD75", 32 | "name" : "musicappUITests" 33 | } 34 | } 35 | ], 36 | "version" : 1 37 | } 38 | -------------------------------------------------------------------------------- /localwave/App.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | import Combine 3 | import MediaPlayer 4 | import os 5 | import SwiftUI 6 | 7 | @main 8 | struct LocalWave: App { 9 | @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 10 | @StateObject private var dependencies: DependencyContainer 11 | @StateObject private var playerVM: PlayerViewModel 12 | @StateObject private var tabState = TabState() 13 | 14 | init() { 15 | let c = try! DependencyContainer() 16 | _dependencies = StateObject(wrappedValue: c) 17 | 18 | _playerVM = StateObject( 19 | wrappedValue: PlayerViewModel( 20 | playerPersistenceService: c.playerPersistenceService, songRepo: c.songRepository, 21 | playlistRepo: c.playlistRepo, playlistSongRepo: c.playlistSongRepo 22 | )) 23 | } 24 | 25 | var body: some Scene { 26 | WindowGroup { 27 | MainTabView() 28 | .environmentObject(tabState) 29 | .environmentObject(dependencies) 30 | .environmentObject(playerVM) 31 | .applyTheme() 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /localwave/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /localwave/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "idiom" : "universal", 16 | "platform" : "ios", 17 | "size" : "1024x1024" 18 | }, 19 | { 20 | "appearances" : [ 21 | { 22 | "appearance" : "luminosity", 23 | "value" : "tinted" 24 | } 25 | ], 26 | "idiom" : "universal", 27 | "platform" : "ios", 28 | "size" : "1024x1024" 29 | } 30 | ], 31 | "info" : { 32 | "author" : "xcode", 33 | "version" : 1 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /localwave/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /localwave/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | UIAppFonts 6 | 7 | Oxanium-VariableFont_wght.ttf 8 | 9 | UIBackgroundModes 10 | 11 | audio 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /localwave/Preview Content/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nexo-tech/localwave/29704bcccd8df94b62a1c3c20d8d12474eae1d32/localwave/Preview Content/.gitkeep -------------------------------------------------------------------------------- /localwave/Sources/App/DependencyContainer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import os 3 | import SwiftUI 4 | 5 | // MARK: - Complete SQLitePlaylistSongRepository implementation 6 | 7 | @MainActor 8 | class DependencyContainer: ObservableObject { 9 | let userService: UserService 10 | let userCloudService: UserCloudService 11 | let icloudProvider: ICloudProvider 12 | let sourceService: SourceService 13 | let songRepository: SongRepository 14 | let songImportService: SongImportService 15 | let playerPersistenceService: PlayerPersistenceService 16 | let playlistRepo: PlaylistRepository 17 | let playlistSongRepo: PlaylistSongRepository 18 | 19 | private var backgroundFileService: BackgroundFileService? 20 | 21 | @Published var sourceBrowseViewModels: [Int64: SourceBrowseViewModel] = [:] 22 | 23 | let logger = Logger(subsystem: subsystem, category: "DependencyContainer") 24 | 25 | func handleAppLaunch() { 26 | logger.debug("Handling app launch...") 27 | startBackgroundServices() 28 | verifyPendingCopies() 29 | logger.debug("App launch handling complete") 30 | } 31 | 32 | private func verifyPendingCopies() { 33 | Task(priority: .utility) { 34 | let pending = await songRepository.getSongsNeedingCopy() 35 | logger.debug("Found \(pending.count) songs needing copy verification") 36 | } 37 | } 38 | 39 | init() throws { 40 | guard let db = setupSQLiteConnection(dbName: "musicApp\(schemaVersion).sqlite") else { 41 | throw CustomError.genericError("database initialisation failed") 42 | } 43 | 44 | let userRepo = try SQLiteUserRepository(db: db) 45 | let sourceRepo = try SQLiteSourceRepository(db: db) 46 | let sourcePathRepo = try SQLiteSourcePathRepository(db: db) 47 | let sourcePathSearchRepository = try SQLiteSourcePathSearchRepository(db: db) 48 | let songRepo = try SQLiteSongRepository(db: db) 49 | 50 | userService = DefaultUserService(userRepository: userRepo) 51 | icloudProvider = DefaultICloudProvider() 52 | userCloudService = DefaultUserCloudService( 53 | userService: userService, 54 | iCloudProvider: icloudProvider 55 | ) 56 | 57 | let sourceSyncService = DefaultSourceSyncService( 58 | sourceRepository: sourceRepo, 59 | sourcePathSearchRepository: sourcePathSearchRepository, 60 | sourcePathRepository: sourcePathRepo 61 | ) 62 | 63 | let sourceImportService = DefaultSourceImportService( 64 | sourceRepository: sourceRepo, 65 | sourcePathRepository: sourcePathRepo, 66 | sourcePathSearchRepository: sourcePathSearchRepository 67 | ) 68 | sourceService = DefaultSourceService( 69 | sourceRepo: sourceRepo, 70 | sourceSyncService: sourceSyncService, 71 | sourceImportService: sourceImportService 72 | ) 73 | songRepository = songRepo 74 | songImportService = DefaultSongImportService( 75 | songRepo: songRepo, 76 | sourcePathRepo: sourcePathRepo, 77 | sourceRepo: sourceRepo 78 | ) 79 | playerPersistenceService = DefaultPlayerPersistenceService(songRepo: songRepo) 80 | playlistRepo = try SQLitePlaylistRepository(db: db) 81 | playlistSongRepo = try SQLitePlaylistSongRepository(db: db) 82 | backgroundFileService = BackgroundFileService(songRepo: songRepo) 83 | } 84 | 85 | func makeSongListViewModel(filter: SongListViewModel.Filter) -> SongListViewModel { 86 | SongListViewModel(songRepo: songRepository, filter: filter) 87 | } 88 | 89 | func makeArtistListViewModel() -> ArtistListViewModel { 90 | ArtistListViewModel(songRepo: songRepository) 91 | } 92 | 93 | func makeAlbumListViewModel() -> AlbumListViewModel { 94 | AlbumListViewModel(songRepo: songRepository) 95 | } 96 | 97 | func makePlaylistListViewModel() -> PlaylistListViewModel { 98 | PlaylistListViewModel( 99 | playlistRepo: playlistRepo, 100 | playlistSongRepo: playlistSongRepo, 101 | songRepo: songRepository 102 | ) 103 | } 104 | 105 | func makeSyncViewModel() -> SyncViewModel { 106 | SyncViewModel( 107 | userCloudService: userCloudService, 108 | icloudProvider: icloudProvider, 109 | sourceService: sourceService, 110 | songImportService: songImportService 111 | ) 112 | } 113 | 114 | private func startBackgroundServices() { 115 | logger.debug("starting background service...") 116 | guard let service = backgroundFileService else { 117 | logger.error("failed to initialise background file service") 118 | return 119 | } 120 | 121 | Task { 122 | await service.start() 123 | logger.debug("background file service startup triggered") 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /localwave/Sources/App/MusicApp.swift: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nexo-tech/localwave/29704bcccd8df94b62a1c3c20d8d12474eae1d32/localwave/Sources/App/MusicApp.swift -------------------------------------------------------------------------------- /localwave/Sources/Core/Utils.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | import CryptoKit 3 | import os 4 | import SQLite 5 | 6 | let subsystem = "com.snowbear.localwave" 7 | let schemaVersion = 29 8 | 9 | enum CustomError: Error { 10 | case genericError(_ message: String) 11 | } 12 | 13 | extension Collection { 14 | subscript(safe index: Index) -> Element? { 15 | return indices.contains(index) ? self[index] : nil 16 | } 17 | } 18 | 19 | struct PathSearchResult { 20 | let pathId: Int64 21 | let rank: Double 22 | init(pathId: Int64, rank: Double) { 23 | self.pathId = pathId 24 | self.rank = rank 25 | } 26 | } 27 | 28 | struct SourceSyncResult { 29 | let allItems: [SourceSyncResultItem] 30 | let audioFiles: [SourceSyncResultItem] 31 | let totalAudioFiles: Int 32 | } 33 | 34 | struct SourceSyncResultItem { 35 | let relativePath: String 36 | let parentURL: URL? 37 | let url: URL 38 | let isDirectory: Bool 39 | let name: String 40 | 41 | init(rootURL: URL, current: URL, isDirectory: Bool) { 42 | let fh = FileHelper(fileURL: current) 43 | relativePath = fh.relativePath(from: rootURL) ?? "" 44 | parentURL = fh.parent().flatMap { 45 | $0 46 | } 47 | url = current 48 | self.isDirectory = isDirectory 49 | name = fh.name() 50 | } 51 | } 52 | 53 | struct FileHelper { 54 | let fileURL: URL 55 | func toString() -> String { 56 | return fileURL.absoluteString 57 | } 58 | 59 | func name() -> String { 60 | return fileURL.lastPathComponent 61 | } 62 | 63 | func parent() -> URL? { 64 | return fileURL.deletingLastPathComponent() 65 | } 66 | 67 | func relativePath(from baseURL: URL) -> String? { 68 | let basePath = baseURL.path 69 | let fullPath = fileURL.path 70 | guard fullPath.hasPrefix(basePath) else { 71 | return nil 72 | } 73 | return String(fullPath.dropFirst(basePath.count + 1)) 74 | } 75 | 76 | static func createURL(baseURL: URL, relativePath: String) -> URL? { 77 | if relativePath.isEmpty { 78 | return baseURL.absoluteURL // If the relative path is empty, return the base URL 79 | } 80 | return baseURL.appendingPathComponent(relativePath).absoluteURL 81 | } 82 | } 83 | 84 | func setupSQLiteConnection(dbName: String) -> Connection? { 85 | let logger = Logger(subsystem: subsystem, category: "setupSQLiteConnection") 86 | logger.debug("setting up connection ...") 87 | let dbPath = NSSearchPathForDirectoriesInDomains( 88 | .documentDirectory, 89 | .userDomainMask, 90 | true 91 | ).first! 92 | let dbFullPath = "\(dbPath)/\(dbName)" 93 | logger.debug("db path: \(dbFullPath)") 94 | do { 95 | return try Connection(dbFullPath) 96 | } catch { 97 | fatalError("DB init error: \(error)") 98 | } 99 | } 100 | 101 | func hashStringToInt64(_ str: String) -> Int64 { 102 | let fnvOffsetBasis: UInt64 = 0xCBF2_9CE4_8422_2325 103 | let fnvPrime: UInt64 = 0x100_0000_01B3 104 | var hash = fnvOffsetBasis 105 | 106 | for byte in str.utf8 { 107 | hash ^= UInt64(byte) 108 | hash = hash &* fnvPrime 109 | } 110 | 111 | return Int64(bitPattern: hash & 0x7FFF_FFFF_FFFF_FFFF) 112 | } 113 | 114 | func generateSongKey(artist: String, title: String, album: String) -> Int64 { 115 | // Normalize or lowercased if you like 116 | let combined = "\(artist.lowercased())__\(title.lowercased())__\(album.lowercased())" 117 | return hashStringToInt64(combined) // Using your existing FNV approach 118 | } 119 | 120 | func makeURLHash(_ folderURL: URL) -> Int64 { 121 | return hashStringToInt64(folderURL.normalizedWithoutTrailingSlash.absoluteString) 122 | } 123 | 124 | func makeBookmarkKey(_ folderURL: URL) -> String { 125 | return String(makeURLHash(folderURL)) 126 | } 127 | 128 | // Could be anything 129 | // file:// 130 | // or path 131 | // or other url 132 | func makeURLFromString(_ s: String) -> URL { 133 | // Trim whitespace and newlines. 134 | let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines) 135 | 136 | // If the string is empty, fallback to the current directory. 137 | if trimmed.isEmpty { 138 | return URL(fileURLWithPath: FileManager.default.currentDirectoryPath) 139 | } 140 | 141 | // Try parsing as a URL. If it has a scheme (like "http", "file", etc.), return it. 142 | if let url = URL(string: trimmed), let scheme = url.scheme, !scheme.isEmpty { 143 | return url 144 | } 145 | 146 | // If it starts with "/" or "~", assume it's a file path. 147 | if trimmed.hasPrefix("/") || trimmed.hasPrefix("~") { 148 | return URL(fileURLWithPath: (trimmed as NSString).expandingTildeInPath) 149 | } 150 | 151 | // If it contains a dot and no spaces, assume it’s a web address missing the scheme. 152 | if trimmed.contains(".") && !trimmed.contains(" ") { 153 | if let url = URL(string: "http://\(trimmed)") { 154 | return url 155 | } 156 | } 157 | 158 | // Fallback: treat it as a file path. 159 | return URL(fileURLWithPath: trimmed) 160 | } 161 | 162 | extension URL { 163 | /// Returns a normalized URL with no trailing slash in its path (unless it's just "/" for root). 164 | var normalizedWithoutTrailingSlash: URL { 165 | // Standardize the URL first 166 | let standardizedURL = standardized 167 | // Use URLComponents to safely modify the path 168 | guard var components = URLComponents(url: standardizedURL, resolvingAgainstBaseURL: false) 169 | else { 170 | return standardizedURL 171 | } 172 | 173 | // Only modify if the path isn’t root and ends with a slash 174 | if components.path != "/" && components.path.hasSuffix("/") { 175 | // Remove all trailing slashes (leaving at least one character) 176 | while components.path.count > 1 && components.path.hasSuffix("/") { 177 | components.path.removeLast() 178 | } 179 | } 180 | 181 | return components.url ?? standardizedURL 182 | } 183 | } 184 | 185 | enum NotImplementedError: Error { 186 | case featureNotImplemented(message: String) 187 | } 188 | 189 | func preprocessFTSQuery(_ input: String) -> String { 190 | input 191 | .components(separatedBy: .whitespacesAndNewlines) 192 | .filter { !$0.isEmpty } 193 | .map { "\($0)*" } 194 | .joined(separator: " ") 195 | } 196 | -------------------------------------------------------------------------------- /localwave/Sources/Data/Providers/DefaultICloudProvider.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import os 3 | import SQLite 4 | 5 | class DefaultICloudProvider: ICloudProvider { 6 | let logger = Logger(subsystem: subsystem, category: "ICloudProvider") 7 | func isICloudAvailable() -> Bool { 8 | return FileManager.default.ubiquityIdentityToken != nil 9 | } 10 | 11 | func getCurrentICloudUserID() async throws -> Int64? { 12 | logger.debug("Attempting to get current iCloud user") 13 | if let ubiquityIdentityToken = FileManager.default.ubiquityIdentityToken { 14 | let tokenData = try NSKeyedArchiver.archivedData( 15 | withRootObject: ubiquityIdentityToken, requiringSecureCoding: true 16 | ) 17 | let tokenString = tokenData.base64EncodedString() 18 | let hashed = hashStringToInt64(tokenString) 19 | logger.debug("Current iCloud user token: \(hashed)") 20 | return hashed 21 | } else { 22 | logger.debug("No iCloud account is signed in.") 23 | return nil 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /localwave/Sources/Data/Repositories/SQLitePlaylistRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import os 3 | import SQLite 4 | 5 | actor SQLitePlaylistRepository: PlaylistRepository { 6 | private let db: Connection 7 | private let table = Table("playlists") 8 | private let colId: SQLite.Expression 9 | private let colName: SQLite.Expression 10 | private let colCreatedAt: SQLite.Expression 11 | private let colUpdatedAt: SQLite.Expression 12 | private let logger = Logger(subsystem: subsystem, category: "SQLitePlaylistRepository") 13 | 14 | init(db: Connection) throws { 15 | self.db = db 16 | // Column definitions 17 | let colId = SQLite.Expression("id") 18 | let colName = SQLite.Expression("name") 19 | let colCreatedAt = SQLite.Expression("createdAt") 20 | let colUpdatedAt = SQLite.Expression("updatedAt") 21 | 22 | try db.run( 23 | table.create(ifNotExists: true) { t in 24 | t.column(colId, primaryKey: .autoincrement) 25 | t.column(colName) 26 | t.column(colCreatedAt) 27 | t.column(colUpdatedAt) 28 | }) 29 | 30 | self.colId = colId 31 | self.colName = colName 32 | self.colCreatedAt = colCreatedAt 33 | self.colUpdatedAt = colUpdatedAt 34 | } 35 | 36 | func create(playlist: Playlist) async throws -> Playlist { 37 | let insert = table.insert( 38 | colName <- playlist.name, 39 | colCreatedAt <- playlist.createdAt, 40 | colUpdatedAt <- playlist.updatedAt 41 | ) 42 | let rowId = try db.run(insert) 43 | return Playlist( 44 | id: rowId, name: playlist.name, createdAt: playlist.createdAt, 45 | updatedAt: playlist.updatedAt 46 | ) 47 | } 48 | 49 | func update(playlist: Playlist) async throws -> Playlist { 50 | guard let playlistId = playlist.id else { 51 | throw CustomError.genericError("Cannot update playlist without ID") 52 | } 53 | 54 | let query = table.filter(colId == playlistId) 55 | try db.run( 56 | query.update( 57 | colName <- playlist.name, 58 | colUpdatedAt <- Date() 59 | )) 60 | 61 | return Playlist( 62 | id: playlistId, 63 | name: playlist.name, 64 | createdAt: playlist.createdAt, 65 | updatedAt: Date() 66 | ) 67 | } 68 | 69 | func delete(playlistId: Int64) async throws { 70 | let query = table.filter(colId == playlistId) 71 | try db.run(query.delete()) 72 | } 73 | 74 | func getAll() async throws -> [Playlist] { 75 | try db.prepare(table).map { row in 76 | Playlist( 77 | id: row[colId], 78 | name: row[colName], 79 | createdAt: row[colCreatedAt], 80 | updatedAt: row[colUpdatedAt] 81 | ) 82 | } 83 | } 84 | 85 | func getOne(id: Int64) async throws -> Playlist? { 86 | let query = table.filter(colId == id) 87 | return try db.pluck(query).map { row in 88 | Playlist( 89 | id: row[colId], 90 | name: row[colName], 91 | createdAt: row[colCreatedAt], 92 | updatedAt: row[colUpdatedAt] 93 | ) 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /localwave/Sources/Data/Repositories/SQLitePlaylistSongRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import os 3 | import SQLite 4 | 5 | actor SQLitePlaylistSongRepository: PlaylistSongRepository { 6 | private let db: Connection 7 | private let table = Table("playlist_songs") 8 | private let colId: SQLite.Expression 9 | private let colPlaylistId: SQLite.Expression 10 | private let colSongId: SQLite.Expression 11 | private let colPosition: SQLite.Expression 12 | private let logger = Logger(subsystem: subsystem, category: "SQLitePlaylistSongRepository") 13 | 14 | init(db: Connection) throws { 15 | self.db = db 16 | // Column definitions 17 | let colId = SQLite.Expression("id") 18 | let colPlaylistId = SQLite.Expression("playlistId") 19 | let colSongId = SQLite.Expression("songId") 20 | let colPosition = SQLite.Expression("position") 21 | 22 | try db.run( 23 | table.create(ifNotExists: true) { t in 24 | t.column(colId, primaryKey: .autoincrement) 25 | t.column(colPlaylistId) 26 | t.column(colSongId) 27 | t.column(colPosition) 28 | t.unique(colPlaylistId, colSongId) // Prevent duplicates 29 | } 30 | ) 31 | 32 | self.colId = colId 33 | self.colPlaylistId = colPlaylistId 34 | self.colSongId = colSongId 35 | self.colPosition = colPosition 36 | } 37 | 38 | func addSong(playlistId: Int64, songId: Int64) async throws { 39 | // Get current max position 40 | let maxPosition = 41 | try db.scalar( 42 | table.filter(colPlaylistId == playlistId) 43 | .select(colPosition.max) 44 | ) ?? -1 45 | 46 | let insert = table.insert( 47 | colPlaylistId <- playlistId, 48 | colSongId <- songId, 49 | colPosition <- maxPosition + 1 50 | ) 51 | try db.run(insert) 52 | } 53 | 54 | func removeSong(playlistId: Int64, songId: Int64) async throws { 55 | let query = table.filter(colPlaylistId == playlistId && colSongId == songId) 56 | try db.run(query.delete()) 57 | } 58 | 59 | func getSongs(playlistId: Int64) async throws -> [Song] { 60 | let songsTable = Table("songs") 61 | let songIdCol = SQLite.Expression("id") 62 | 63 | return try db.prepare( 64 | table 65 | .join(songsTable, on: songsTable[songIdCol] == table[colSongId]) 66 | .filter(colPlaylistId == playlistId) 67 | .order(colPosition.asc) 68 | ).map { row in 69 | Song( 70 | id: row[songsTable[songIdCol]], 71 | songKey: row[songsTable[Expression("songKey")]], 72 | artist: row[songsTable[Expression("artist")]], 73 | title: row[songsTable[Expression("title")]], 74 | album: row[songsTable[Expression("album")]], 75 | albumArtist: row[songsTable[Expression("albumArtist")]], 76 | releaseYear: row[songsTable[Expression("releaseYear")]], 77 | discNumber: row[songsTable[Expression("discNumber")]], 78 | trackNumber: row[songsTable[Expression("trackNumber")]], 79 | coverArtPath: row[songsTable[Expression("coverArtPath")]], 80 | bookmark: (row[songsTable[SQLite.Expression("bookmark")]]?.bytes).map { 81 | Data($0) 82 | }, 83 | 84 | pathHash: row[songsTable[Expression("pathHash")]], 85 | createdAt: Date( 86 | timeIntervalSince1970: row[songsTable[Expression("createdAt")]]), 87 | updatedAt: row[songsTable[Expression("updatedAt")]].map( 88 | Date.init(timeIntervalSince1970:)), 89 | localFilePath: row[songsTable[Expression("localFilePath")]], 90 | fileState: FileState(rawValue: row[songsTable[Expression("fileState")]]) 91 | ?? .bookmarkOnly 92 | ) 93 | } 94 | } 95 | 96 | func reorderSongs(playlistId: Int64, newOrder: [Int64]) async throws { 97 | try db.transaction { 98 | // Clear existing positions 99 | try db.run(table.filter(colPlaylistId == playlistId).update(colPosition <- -1)) 100 | 101 | // Update with new positions 102 | for (index, songId) in newOrder.enumerated() { 103 | let query = table.filter(colPlaylistId == playlistId && colSongId == songId) 104 | try db.run(query.update(colPosition <- index)) 105 | } 106 | 107 | // Cleanup any invalid entries (shouldn't be necessary) 108 | try db.run(table.filter(colPlaylistId == playlistId && colPosition == -1).delete()) 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /localwave/Sources/Data/Repositories/SQLiteSourcePathRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import os 3 | import SQLite 4 | 5 | actor SQLiteSourcePathRepository: SourcePathRepository { 6 | private let db: Connection 7 | private let table = Table("source_paths") 8 | 9 | private let colId: SQLite.Expression 10 | private let colSourceId: SQLite.Expression 11 | private let colPathId: SQLite.Expression 12 | private let colParentPathId: SQLite.Expression 13 | private let colName: SQLite.Expression 14 | private let colRelativePath: SQLite.Expression 15 | private let colIsDirectory: SQLite.Expression 16 | private let colFileHashSHA256: SQLite.Expression 17 | private let colRunId: SQLite.Expression 18 | private let colCreatedAt: SQLite.Expression 19 | private let colUpdatedAt: SQLite.Expression 20 | 21 | private let logger = Logger(subsystem: subsystem, category: "SQLiteSourcePathRepository") 22 | 23 | func getByPathId(sourceId: Int64, pathId: Int64) async throws -> SourcePath? { 24 | let query = table.filter(colSourceId == sourceId && colPathId == pathId) 25 | if let row = try db.pluck(query) { 26 | return SourcePath( 27 | id: row[colId], 28 | sourceId: row[colSourceId], 29 | pathId: row[colPathId], 30 | parentPathId: row[colParentPathId], 31 | name: row[colName], 32 | relativePath: row[colRelativePath], 33 | isDirectory: row[colIsDirectory], 34 | fileHashSHA256: row[colFileHashSHA256], 35 | runId: row[colRunId], 36 | createdAt: row[colCreatedAt], 37 | updatedAt: row[colUpdatedAt] 38 | ) 39 | } 40 | return nil 41 | } 42 | 43 | func deleteAllPaths(sourceId: Int64) async throws { 44 | let query = table.filter(colSourceId == sourceId) 45 | try db.run(query.delete()) 46 | logger.debug("Deleted all paths for source: \(sourceId)") 47 | } 48 | 49 | func getByParentId(sourceId: Int64, parentPathId: Int64?) async throws -> [SourcePath] { 50 | let rows: AnySequence 51 | if let parentId = parentPathId { 52 | let query = table.filter(colSourceId == sourceId && colParentPathId == parentId) 53 | rows = try db.prepare(query) 54 | } else { 55 | let query = table.filter(colSourceId == sourceId) 56 | rows = try db.prepare(query) 57 | } 58 | return rows.map { row in 59 | SourcePath( 60 | id: row[colId], 61 | sourceId: row[colSourceId], 62 | pathId: row[colPathId], 63 | parentPathId: row[colParentPathId], 64 | name: row[colName], 65 | relativePath: row[colRelativePath], 66 | isDirectory: row[colIsDirectory], 67 | fileHashSHA256: row[colFileHashSHA256], 68 | runId: row[colRunId], 69 | createdAt: row[colCreatedAt], 70 | updatedAt: row[colUpdatedAt] 71 | ) 72 | } 73 | } 74 | 75 | // MARK: - Initializer 76 | 77 | init(db: Connection) throws { 78 | let colId = SQLite.Expression("id") 79 | let colSourceId = SQLite.Expression("sourceId") 80 | let colPathId = SQLite.Expression("pathId") 81 | let colParentPathId = SQLite.Expression("parentPathId") 82 | let colName = SQLite.Expression("name") 83 | let colRelativePath = SQLite.Expression("relativePath") 84 | let colIsDirectory = SQLite.Expression("isDirectory") 85 | let colFileHashSHA256 = SQLite.Expression("fileHashSHA256") 86 | let colRunId = SQLite.Expression("runId") 87 | let colCreatedAt = SQLite.Expression("createdAt") 88 | let colUpdatedAt = SQLite.Expression("updatedAt") 89 | 90 | self.db = db 91 | 92 | try db.run( 93 | table.create(ifNotExists: true) { t in 94 | t.column(colId, primaryKey: .autoincrement) 95 | t.column(colSourceId) 96 | t.column(colPathId) 97 | t.column(colParentPathId) 98 | t.column(colName) 99 | t.column(colRelativePath) 100 | t.column(colIsDirectory) 101 | t.column(colFileHashSHA256) 102 | t.column(colRunId) 103 | t.column(colCreatedAt) 104 | t.column(colUpdatedAt) 105 | } 106 | ) 107 | logger.debug("Created table: source_paths") 108 | 109 | self.colId = colId 110 | self.colSourceId = colSourceId 111 | self.colPathId = colPathId 112 | self.colParentPathId = colParentPathId 113 | self.colName = colName 114 | self.colRelativePath = colRelativePath 115 | self.colIsDirectory = colIsDirectory 116 | self.colFileHashSHA256 = colFileHashSHA256 117 | self.colRunId = colRunId 118 | self.colCreatedAt = colCreatedAt 119 | self.colUpdatedAt = colUpdatedAt 120 | } 121 | 122 | func deleteMany(sourceId: Int64, excludingRunId: Int64) async throws -> Int { 123 | let query = table.filter(colSourceId == sourceId && colRunId != excludingRunId) 124 | let count = try db.run(query.delete()) 125 | logger.debug( 126 | "Deleted \(count) source paths for sourceId: \(sourceId) excluding runId: \(excludingRunId)" 127 | ) 128 | return count 129 | } 130 | 131 | // MARK: - Create 132 | 133 | func create(path: SourcePath) async throws -> SourcePath { 134 | let insert = table.insert( 135 | colSourceId <- path.sourceId, 136 | colPathId <- path.pathId, 137 | colParentPathId <- path.parentPathId, 138 | colName <- path.name, 139 | colRelativePath <- path.relativePath, 140 | colIsDirectory <- path.isDirectory, 141 | colFileHashSHA256 <- path.fileHashSHA256, 142 | colRunId <- path.runId, 143 | colCreatedAt <- path.createdAt, 144 | colUpdatedAt <- path.updatedAt 145 | ) 146 | let rowId = try db.run(insert) 147 | logger.debug("Inserted source path with ID: \(rowId)") 148 | return path.copyWith(id: rowId) 149 | } 150 | 151 | // MARK: - Update File Hash 152 | 153 | func updateFileHash(pathId: Int64, fileHash: Data?) async throws { 154 | let query = table.filter(colPathId == pathId) 155 | try db.run(query.update(colFileHashSHA256 <- fileHash)) 156 | logger.debug("Updated file hash for path ID: \(pathId)") 157 | } 158 | 159 | // MARK: - Delete Many 160 | 161 | func deleteMany(sourceId: Int64) async throws { 162 | let query = table.filter(colSourceId == sourceId) 163 | let count = try db.run(query.delete()) 164 | logger.debug("Deleted \(count) source paths for source ID: \(sourceId)") 165 | } 166 | 167 | // MARK: - Get By Parent ID 168 | 169 | func getByParentId(parentId: Int64) async throws -> [SourcePath] { 170 | try db.prepare(table.filter(colParentPathId == parentId)).map { row in 171 | SourcePath( 172 | id: row[colId], 173 | sourceId: row[colSourceId], 174 | pathId: row[colPathId], 175 | parentPathId: row[colParentPathId], 176 | name: row[colName], 177 | relativePath: row[colRelativePath], 178 | isDirectory: row[colIsDirectory], 179 | fileHashSHA256: row[colFileHashSHA256], 180 | runId: row[colRunId], 181 | createdAt: row[colCreatedAt], 182 | updatedAt: row[colUpdatedAt] 183 | ) 184 | } 185 | } 186 | 187 | // MARK: - Get By Path 188 | 189 | func getByPath(relativePath: String, sourceId: Int64) async throws -> SourcePath? { 190 | let query = table.filter(colRelativePath == relativePath && colSourceId == sourceId) 191 | if let row = try db.pluck(query) { 192 | return SourcePath( 193 | id: row[colId], 194 | sourceId: row[colSourceId], 195 | pathId: row[colPathId], 196 | parentPathId: row[colParentPathId], 197 | name: row[colName], 198 | relativePath: row[colRelativePath], 199 | isDirectory: row[colIsDirectory], 200 | fileHashSHA256: row[colFileHashSHA256], 201 | runId: row[colRunId], 202 | createdAt: row[colCreatedAt], 203 | updatedAt: row[colUpdatedAt] 204 | ) 205 | } 206 | return nil 207 | } 208 | 209 | func batchUpsert(paths: [SourcePath]) async throws { 210 | if paths.count == 0 { 211 | return 212 | } 213 | try db.transaction { 214 | for path in paths { 215 | let query = table.filter(colSourceId == path.sourceId && colPathId == path.pathId) 216 | if try (db.pluck(query)) != nil { 217 | // Update existing record 218 | try db.run( 219 | query.update( 220 | colParentPathId <- path.parentPathId, 221 | colName <- path.name, 222 | colRelativePath <- path.relativePath, 223 | colIsDirectory <- path.isDirectory, 224 | colFileHashSHA256 <- path.fileHashSHA256, 225 | colRunId <- path.runId, 226 | colUpdatedAt <- path.updatedAt 227 | )) 228 | logger.debug( 229 | "Updated source path with sourceId: \(path.sourceId), pathId: \(path.pathId)" 230 | ) 231 | } else { 232 | // Insert new record 233 | try db.run( 234 | table.insert( 235 | colSourceId <- path.sourceId, 236 | colPathId <- path.pathId, 237 | colParentPathId <- path.parentPathId, 238 | colName <- path.name, 239 | colRelativePath <- path.relativePath, 240 | colIsDirectory <- path.isDirectory, 241 | colFileHashSHA256 <- path.fileHashSHA256, 242 | colRunId <- path.runId, 243 | colCreatedAt <- path.createdAt, 244 | colUpdatedAt <- path.updatedAt 245 | )) 246 | logger.debug( 247 | "Inserted new source path with sourceId: \(path.sourceId), pathId: \(path.pathId)" 248 | ) 249 | } 250 | } 251 | } 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /localwave/Sources/Data/Repositories/SQLiteSourcePathSearchRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import os 3 | import SQLite 4 | 5 | actor SQLiteSourcePathSearchRepository: SourcePathSearchRepository { 6 | func search(sourceId: Int64, query: String, limit: Int) async throws -> [PathSearchResult] { 7 | let processedQuery = preprocessFTSQuery(query) 8 | 9 | let sql = """ 10 | SELECT pathId, bm25(source_paths_fts) AS rank 11 | FROM source_paths_fts 12 | WHERE source_paths_fts MATCH ? 13 | AND sourceId = ? 14 | ORDER BY rank 15 | LIMIT ?; 16 | """ 17 | 18 | var results: [PathSearchResult] = [] 19 | for row in try db.prepare(sql, processedQuery, sourceId, limit) { 20 | let pathId = row[0] as? Int64 ?? 0 21 | let rank = row[1] as? Double ?? 0.0 22 | results.append(PathSearchResult(pathId: pathId, rank: rank)) 23 | } 24 | return results 25 | } 26 | 27 | func deleteAllFTS(sourceId: Int64) async throws { 28 | let query = ftsTable.filter(colFtsSourceId == sourceId) 29 | try db.run(query.delete()) 30 | logger.debug("Deleted all FTS entries for source: \(sourceId)") 31 | } 32 | 33 | private let logger = Logger(subsystem: subsystem, category: "SourcePathSearchRepository") 34 | 35 | // MARK: - Batch Delete by sourceId, excluding runId 36 | 37 | func batchDeleteFTS(sourceId: Int64, excludingRunId: Int64) async throws { 38 | // Delete all rows with this sourceId where runId != excludingRunId 39 | let query = ftsTable.filter( 40 | colFtsSourceId == sourceId && colFtsRunId != excludingRunId 41 | ) 42 | try db.transaction { 43 | try db.run(query.delete()) 44 | } 45 | } 46 | 47 | // MARK: - Batch Upsert 48 | 49 | /// If `(sourceId, pathId)` already exists, we update `runId`, `fullPath`, `fileName`. 50 | /// Otherwise, we insert a new row. 51 | func batchUpsertIntoFTS(paths: [SourcePath]) async throws { 52 | guard !paths.isEmpty else { return } 53 | 54 | try db.transaction { 55 | for path in paths { 56 | let existingQuery = self.ftsTable.filter( 57 | self.colFtsPathId == path.pathId && self.colFtsSourceId == path.sourceId 58 | ) 59 | if try db.pluck(existingQuery) != nil { 60 | // Update 61 | try db.run( 62 | existingQuery.update( 63 | self.colFtsRunId <- path.runId, 64 | self.colFtsFullPath <- path.relativePath, 65 | self.colFtsFileName <- path.name 66 | ) 67 | ) 68 | } else { 69 | // Insert 70 | try db.run( 71 | self.ftsTable.insert( 72 | self.colFtsPathId <- path.pathId, 73 | self.colFtsSourceId <- path.sourceId, 74 | self.colFtsRunId <- path.runId, 75 | self.colFtsFullPath <- path.relativePath, 76 | self.colFtsFileName <- path.name 77 | ) 78 | ) 79 | } 80 | } 81 | } 82 | } 83 | 84 | private let db: Connection 85 | private let ftsTable = Table("source_paths_fts") 86 | private let colFtsPathId = SQLite.Expression("pathId") 87 | private let colFtsSourceId = SQLite.Expression("sourceId") 88 | private let colFtsRunId = SQLite.Expression("runId") 89 | private let colFtsFullPath = SQLite.Expression("fullPath") 90 | private let colFtsFileName = SQLite.Expression("fileName") 91 | 92 | init(db: Connection) throws { 93 | self.db = db 94 | try db.execute( 95 | """ 96 | CREATE VIRTUAL TABLE IF NOT EXISTS source_paths_fts 97 | USING fts5( 98 | pathId UNINDEXED, 99 | sourceId UNINDEXED, 100 | runId UNINDEXED, 101 | fullPath, 102 | fileName, 103 | tokenize='unicode61' 104 | ); 105 | """) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /localwave/Sources/Data/Repositories/SQLiteSourceRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import os 3 | import SQLite 4 | 5 | actor SQLiteSourceRepository: SourceRepository { 6 | private let db: Connection 7 | private let table = Table("sources") 8 | 9 | // Add type column 10 | private var colType: SQLite.Expression 11 | private var colId: SQLite.Expression 12 | private var colDirPath: SQLite.Expression 13 | private var colPathId: SQLite.Expression 14 | private var colUserId: SQLite.Expression 15 | private var colTotalPaths: SQLite.Expression 16 | private var colSyncError: SQLite.Expression 17 | private var colIsCurrent: SQLite.Expression 18 | private var colCreatedAt: SQLite.Expression 19 | private var colLastSyncedAt: SQLite.Expression 20 | private var colUpdatedAt: SQLite.Expression 21 | 22 | private let logger = Logger(subsystem: subsystem, category: "SQLiteSourceRepository") 23 | 24 | init(db: Connection) throws { 25 | // Existing columns 26 | let colId = SQLite.Expression("id") 27 | let colDirPath = SQLite.Expression("dirPath") 28 | let colPathId = SQLite.Expression("pathId") 29 | let colUserId = SQLite.Expression("userId") 30 | let colTotalPaths = SQLite.Expression("totalPaths") 31 | let colSyncError = SQLite.Expression("syncError") 32 | let colIsCurrent = SQLite.Expression("isCurrent") 33 | let colCreatedAt = SQLite.Expression("createdAt") 34 | let colLastSyncedAt = SQLite.Expression("lastSyncedAt") 35 | let colUpdatedAt = SQLite.Expression("updatedAt") 36 | let colType = SQLite.Expression("type") 37 | 38 | self.db = db 39 | 40 | // Create table with new column 41 | try db.run( 42 | table.create(ifNotExists: true) { t in 43 | t.column(colId, primaryKey: .autoincrement) 44 | t.column(colDirPath) 45 | t.column(colPathId) 46 | t.column(colUserId) 47 | t.column(colType) 48 | t.column(colTotalPaths) 49 | t.column(colSyncError) 50 | t.column(colIsCurrent) 51 | t.column(colCreatedAt) 52 | t.column(colLastSyncedAt) 53 | t.column(colUpdatedAt) 54 | }) 55 | 56 | self.colId = colId 57 | self.colDirPath = colDirPath 58 | self.colPathId = colPathId 59 | self.colUserId = colUserId 60 | self.colTotalPaths = colTotalPaths 61 | self.colSyncError = colSyncError 62 | self.colIsCurrent = colIsCurrent 63 | self.colCreatedAt = colCreatedAt 64 | self.colLastSyncedAt = colLastSyncedAt 65 | self.colUpdatedAt = colUpdatedAt 66 | self.colType = colType 67 | } 68 | 69 | func deleteSource(sourceId: Int64) async throws { 70 | let query = table.filter(colId == sourceId) 71 | try db.run(query.delete()) 72 | logger.debug("Deleted source with ID: \(sourceId)") 73 | } 74 | 75 | func getOne(id: Int64) async throws -> Source? { 76 | let query = table.filter(colId == id) 77 | if let row = try db.pluck(query) { 78 | return Source( 79 | id: row[colId], 80 | dirPath: row[colDirPath], 81 | pathId: row[colPathId], 82 | userId: row[colUserId], 83 | type: row[colType].flatMap(SourceType.init(rawValue:)), // Map from String? 84 | totalPaths: row[colTotalPaths], 85 | syncError: row[colSyncError], 86 | isCurrent: row[colIsCurrent], 87 | createdAt: row[colCreatedAt], 88 | lastSyncedAt: row[colLastSyncedAt], 89 | updatedAt: row[colUpdatedAt] 90 | ) 91 | } 92 | return nil 93 | } 94 | 95 | func create(source: Source) async throws -> Source { 96 | // Force explicit type setting (even if nil) 97 | let insert = table.insert( 98 | colDirPath <- source.dirPath, 99 | colPathId <- source.pathId, 100 | colUserId <- source.userId, 101 | colType <- source.type?.rawValue, // Explicit null if type is nil 102 | colTotalPaths <- source.totalPaths, 103 | colSyncError <- source.syncError, 104 | colIsCurrent <- source.isCurrent, 105 | colCreatedAt <- source.createdAt, 106 | colLastSyncedAt <- source.lastSyncedAt, 107 | colUpdatedAt <- source.updatedAt 108 | ) 109 | 110 | let rowId = try db.run(insert) 111 | logger.debug("Inserted source with ID: \(rowId)") 112 | 113 | return Source( 114 | id: rowId, 115 | dirPath: source.dirPath, 116 | pathId: source.pathId, 117 | userId: source.userId, 118 | type: source.type, // Preserve original type 119 | totalPaths: source.totalPaths, 120 | syncError: source.syncError, 121 | isCurrent: source.isCurrent, 122 | createdAt: source.createdAt, 123 | lastSyncedAt: source.lastSyncedAt, 124 | updatedAt: source.updatedAt 125 | ) 126 | } 127 | 128 | func findOneByUserId(userId: Int64, path: String?) async throws -> [Source] { 129 | var predicate = colUserId == userId 130 | if let path = path { 131 | predicate = predicate && colDirPath == path 132 | } 133 | 134 | return try db.prepare(table.filter(predicate)).map { row in 135 | Source( 136 | id: row[colId], 137 | dirPath: row[colDirPath], 138 | pathId: row[colPathId], 139 | userId: row[colUserId], 140 | type: row[colType].flatMap(SourceType.init(rawValue:)), 141 | totalPaths: row[colTotalPaths], 142 | syncError: row[colSyncError], 143 | isCurrent: row[colIsCurrent], 144 | createdAt: row[colCreatedAt], 145 | lastSyncedAt: row[colLastSyncedAt], 146 | updatedAt: row[colUpdatedAt] 147 | ) 148 | } 149 | } 150 | 151 | func updateSource(source: Source) async throws -> Source { 152 | guard let sourceId = source.id else { 153 | throw NSError(domain: "Invalid source ID", code: 0, userInfo: nil) 154 | } 155 | 156 | let query = table.filter(colId == sourceId) 157 | try db.run( 158 | query.update( 159 | colDirPath <- source.dirPath, 160 | colPathId <- source.pathId, 161 | colType <- source.type?.rawValue, 162 | colTotalPaths <- source.totalPaths, 163 | colSyncError <- source.syncError, 164 | colIsCurrent <- source.isCurrent, 165 | colLastSyncedAt <- source.lastSyncedAt, 166 | colUpdatedAt <- source.updatedAt 167 | )) 168 | 169 | return source 170 | } 171 | 172 | func setCurrentSource(userId: Int64, sourceId: Int64) async throws -> Source { 173 | try db.transaction { 174 | try db.run(table.filter(colUserId == userId).update(colIsCurrent <- false)) 175 | try db.run(table.filter(colId == sourceId).update(colIsCurrent <- true)) 176 | } 177 | 178 | guard let row = try db.pluck(table.filter(colId == sourceId)) else { 179 | throw NSError(domain: "Source not found", code: 0, userInfo: nil) 180 | } 181 | 182 | return Source( 183 | id: row[colId], 184 | dirPath: row[colDirPath], 185 | pathId: row[colPathId], 186 | userId: row[colUserId], 187 | type: row[colType].flatMap(SourceType.init(rawValue:)), 188 | totalPaths: row[colTotalPaths], 189 | syncError: row[colSyncError], 190 | isCurrent: row[colIsCurrent], 191 | createdAt: row[colCreatedAt], 192 | lastSyncedAt: row[colLastSyncedAt], 193 | updatedAt: row[colUpdatedAt] 194 | ) 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /localwave/Sources/Data/Repositories/SQLiteUserRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import os 3 | import SQLite 4 | 5 | let usersTableName = "users" 6 | 7 | actor SQLiteUserRepository: UserRepository { 8 | let logger = Logger(subsystem: subsystem, category: "SQLiteUserRepository") 9 | 10 | func findByIcloudId(icloudId: Int64) throws -> User? { 11 | if let row = try db.pluck(table.filter(colIcloudId == icloudId)) { 12 | return User(id: row[colId], icloudId: row[colIcloudId]) 13 | } 14 | return nil 15 | } 16 | 17 | func create(user: User) throws -> User { 18 | let insert = table.insert(colIcloudId <- user.icloudId) 19 | let rowId = try db.run(insert) 20 | logger.debug("inserted user \(rowId)") 21 | return User(id: rowId, icloudId: user.icloudId) 22 | } 23 | 24 | init(db: Connection) throws { 25 | let colId: SQLite.Expression = Expression("id") 26 | let colIcloudId: SQLite.Expression = Expression("icloudId") 27 | try db.run( 28 | table.create(ifNotExists: true) { t in 29 | t.column(colId, primaryKey: .autoincrement) 30 | t.column(colIcloudId, unique: true) 31 | }) 32 | logger.debug("created table: \(usersTableName)") 33 | self.db = db 34 | self.colId = colId 35 | self.colIcloudId = colIcloudId 36 | } 37 | 38 | let db: Connection 39 | 40 | private let table = Table(usersTableName) 41 | private let colId: SQLite.Expression 42 | private let colIcloudId: SQLite.Expression 43 | } 44 | -------------------------------------------------------------------------------- /localwave/Sources/Data/Services/BackgroundFileService.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | import CryptoKit 3 | import os 4 | 5 | actor BackgroundFileService { 6 | private let songRepo: SongRepository 7 | private let logger = Logger(subsystem: subsystem, category: "BackgroundFileService") 8 | private var isRunning = false 9 | private let maxRetries = 3 10 | 11 | init(songRepo: SongRepository) { 12 | self.songRepo = songRepo 13 | logger.debug("Initialised") 14 | } 15 | 16 | func start() { 17 | logger.debug("attempting to start myself!") 18 | guard !isRunning else { 19 | logger.debug("service already running - aborting restart") 20 | return 21 | } 22 | isRunning = true 23 | logger.debug("service started successfully") 24 | 25 | Task { 26 | while isRunning { 27 | logger.debug("beginning processing cycle") 28 | await processQueue() 29 | logger.debug("processing cycle completed") 30 | try await Task.sleep(nanoseconds: 30 * 1_000_000_000) // 30 seconds 31 | } 32 | } 33 | } 34 | 35 | private func processQueue() async { 36 | let songs = await songRepo.getSongsNeedingCopy() 37 | logger.debug("Processing \(songs.count) files needing copy") 38 | 39 | for song in songs { 40 | do { 41 | logger.debug("attempting cop-y for song id \(song.id ?? -1)") 42 | let updatedSong = try await attemptFileCopy(song: song) 43 | _ = try await songRepo.upsertSong(updatedSong) 44 | logger.debug("succesfully copied song id \(song.id ?? -1)") 45 | } catch { 46 | logger.error("Failed to copy file for song \(song.id ?? -1): \(error)") 47 | await markFailed(song: song) 48 | } 49 | } 50 | } 51 | 52 | private func attemptFileCopy(song: Song) async throws -> Song { 53 | guard let bookmark = song.bookmark else { 54 | throw CustomError.genericError("No bookmark available") 55 | } 56 | 57 | var isStale = false 58 | let sourceURL = try URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &isStale) 59 | guard sourceURL.startAccessingSecurityScopedResource() else { 60 | throw CustomError.genericError("Couldn't access security scoped resource") 61 | } 62 | defer { sourceURL.stopAccessingSecurityScopedResource() } 63 | 64 | // Generate unique filename using hash 65 | let fileData = try Data(contentsOf: sourceURL) 66 | let fileHash = SHA256.hash(data: fileData) 67 | let hashString = fileHash.compactMap { String(format: "%02hhx", $0) }.joined() 68 | let fileExtension = sourceURL.pathExtension 69 | let fileName = "\(hashString).\(fileExtension)" 70 | 71 | // Prepare directories 72 | let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] 73 | let musicDir = docsDir.appendingPathComponent("Music", isDirectory: true) 74 | try FileManager.default.createDirectory(at: musicDir, withIntermediateDirectories: true) 75 | 76 | // Atomic write using temporary file 77 | let tempFile = musicDir.appendingPathComponent(UUID().uuidString) 78 | let finalFile = musicDir.appendingPathComponent(fileName) 79 | 80 | // Cleanup if destination exists 81 | if FileManager.default.fileExists(atPath: finalFile.path) { 82 | try FileManager.default.removeItem(at: finalFile) 83 | } 84 | 85 | try fileData.write(to: tempFile) 86 | try FileManager.default.moveItem(at: tempFile, to: finalFile) 87 | 88 | return song.copyWith("Music/\(fileName)", .copied) 89 | } 90 | 91 | private func markFailed(song: Song) async { 92 | var updated = song 93 | updated.fileState = .failed 94 | _ = try? await songRepo.upsertSong(updated) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /localwave/Sources/Data/Services/DefaultPlayerPersistenceService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import os 3 | 4 | actor DefaultPlayerPersistenceService: PlayerPersistenceService { 5 | private let queueKey = "currentQueue" 6 | private let currentIndexKey = "currentQueueIndex" 7 | private let volumeKey = "playerVolume" 8 | 9 | private let songRepo: SongRepository 10 | 11 | init(songRepo: SongRepository) { 12 | self.songRepo = songRepo 13 | } 14 | 15 | func getVolume() async -> Float { 16 | UserDefaults.standard.float(forKey: volumeKey) 17 | } 18 | 19 | let logger = Logger(subsystem: subsystem, category: "PlayerPersistenceService") 20 | 21 | func restore() async -> ([Song], Int, Song?)? { 22 | guard let songIds = UserDefaults.standard.array(forKey: queueKey) as? [Int64], 23 | let currentIndex = UserDefaults.standard.value(forKey: currentIndexKey) as? Int, 24 | !songIds.isEmpty 25 | else { 26 | logger.debug("no persisted data, skipping") 27 | return nil 28 | } 29 | 30 | // Need to inject song repository 31 | logger.debug("loading songs by ids: \(songIds)") 32 | let songs = await songRepo.getSongs(ids: songIds) 33 | let currentSong = songs[safe: currentIndex] 34 | 35 | return (songs, currentIndex, currentSong) 36 | } 37 | 38 | func savePlaybackState(volume: Float, currentIndex: Int, songs: [Song]) async { 39 | let songIds = songs.map { $0.id ?? -1 } 40 | UserDefaults.standard.set(songIds, forKey: queueKey) 41 | UserDefaults.standard.set(currentIndex, forKey: currentIndexKey) 42 | UserDefaults.standard.set(volume, forKey: volumeKey) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /localwave/Sources/Data/Services/DefaultSourceImportService.swift: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | 4 | actor DefaultSourceImportService: SourceImportService { 5 | let logger = Logger(subsystem: subsystem, category: "SourceImportService") 6 | 7 | private let sourceRepository: SourceRepository 8 | private let sourcePathRepository: SourcePathRepository 9 | private let sourcePathSearchRepository: SourcePathSearchRepository 10 | 11 | init( 12 | sourceRepository: SourceRepository, 13 | sourcePathRepository: SourcePathRepository, 14 | sourcePathSearchRepository: SourcePathSearchRepository 15 | ) { 16 | self.sourceRepository = sourceRepository 17 | self.sourcePathRepository = sourcePathRepository 18 | self.sourcePathSearchRepository = sourcePathSearchRepository 19 | } 20 | 21 | func deleteOne(sourceId: Int64) async throws { 22 | try await sourceRepository.deleteSource(sourceId: sourceId) 23 | try await sourcePathRepository.deleteAllPaths(sourceId: sourceId) 24 | try await sourcePathSearchRepository.deleteAllFTS(sourceId: sourceId) 25 | } 26 | 27 | func listItems(sourceId: Int64, parentPathId: Int64?) async throws -> [SourcePath] { 28 | logger.debug("attempting to list for libID : \(sourceId), parent: \(parentPathId ?? -1)") 29 | let all = try await sourcePathRepository.getByParentId( 30 | sourceId: sourceId, parentPathId: parentPathId 31 | ) 32 | 33 | logger.debug("got : \(all.count) items") 34 | 35 | return all 36 | } 37 | 38 | func search(sourceId: Int64, query: String) async throws -> [SourcePath] { 39 | let results = try await sourcePathSearchRepository.search( 40 | sourceId: sourceId, 41 | query: query, 42 | limit: 100 // or whatever you like 43 | ) 44 | 45 | // Retrieve the actual SourcePath records for each matching pathId 46 | var paths = [SourcePath]() 47 | for r in results { 48 | if let p = try await sourcePathRepository.getByPathId( 49 | sourceId: sourceId, pathId: r.pathId 50 | ) { 51 | paths.append(p) 52 | } 53 | } 54 | return paths 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /localwave/Sources/Data/Services/DefaultSourceService.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | import os 3 | 4 | class DefaultSourceService: SourceService { 5 | func importService() -> any SourceImportService { 6 | return sourceImportService 7 | } 8 | 9 | let logger = Logger(subsystem: subsystem, category: "SourceService") 10 | 11 | func repository() -> SourceRepository { 12 | return sourceRepo 13 | } 14 | 15 | func getCurrentSource(userId: Int64) async throws -> Source? { 16 | let sources = try await sourceRepo.findOneByUserId(userId: userId, path: nil) 17 | if sources.count == 0 { 18 | return nil 19 | } else if let lib = sources.first(where: { $0.isCurrent }) { 20 | return lib 21 | } else { 22 | let lib = sources[0] 23 | return try await sourceRepo.setCurrentSource(userId: userId, sourceId: lib.id!) 24 | } 25 | } 26 | 27 | func registerSourcePath(userId: Int64, path: String, type: SourceType) async throws -> Source { 28 | let source = try await sourceRepo.findOneByUserId(userId: userId, path: path) 29 | if source.count == 0 { 30 | logger.debug("no source found, creating new one") 31 | let pathId = makeURLHash(makeURLFromString(path)) 32 | let src = Source( 33 | id: nil, dirPath: path, pathId: pathId, userId: userId, type: type, totalPaths: nil, 34 | syncError: nil, 35 | isCurrent: true, createdAt: Date(), lastSyncedAt: nil, updatedAt: nil 36 | ) 37 | let source = try await sourceRepo.create(source: src) 38 | logger.debug("updating current switch") 39 | let lib2 = try await sourceRepo.setCurrentSource( 40 | userId: userId, sourceId: source.id! 41 | ) 42 | return lib2 43 | } else if !source[0].isCurrent { 44 | logger.debug("source is found, but it's not current") 45 | let lib2 = try await sourceRepo.setCurrentSource( 46 | userId: userId, sourceId: source[0].id! 47 | ) 48 | return lib2 49 | } else { 50 | return source[0] 51 | } 52 | } 53 | 54 | func syncService() -> SourceSyncService { 55 | return sourceSyncService 56 | } 57 | 58 | private var sourceRepo: SourceRepository 59 | private var sourceImportService: SourceImportService 60 | private var sourceSyncService: SourceSyncService 61 | 62 | init( 63 | sourceRepo: SourceRepository, sourceSyncService: SourceSyncService, 64 | sourceImportService: SourceImportService 65 | ) { 66 | self.sourceRepo = sourceRepo 67 | self.sourceSyncService = sourceSyncService 68 | self.sourceImportService = sourceImportService 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /localwave/Sources/Data/Services/DefaultSourceSyncService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import os 3 | 4 | actor DefaultSourceSyncService: SourceSyncService { 5 | let logger = Logger(subsystem: subsystem, category: "SourceSyncService") 6 | 7 | let sourceRepository: SourceRepository 8 | let sourcePathRepository: SourcePathRepository 9 | let sourcePathSearchRepository: SourcePathSearchRepository 10 | 11 | init( 12 | sourceRepository: SourceRepository, 13 | sourcePathSearchRepository: SourcePathSearchRepository, 14 | sourcePathRepository: SourcePathRepository 15 | ) { 16 | self.sourceRepository = sourceRepository 17 | self.sourcePathRepository = sourcePathRepository 18 | self.sourcePathSearchRepository = sourcePathSearchRepository 19 | } 20 | 21 | func syncDir( 22 | sourceId: Int64, folderURL: URL, onCurrentURL: ((_ url: URL?) -> Void)?, 23 | onSetLoading: ((_ loading: Bool) -> Void)? 24 | ) async throws 25 | -> Source? 26 | { 27 | logger.debug("starting to collect items") 28 | do { 29 | onSetLoading?(true) 30 | defer { onSetLoading?(false) } 31 | let result = try await syncDirInner( 32 | folderURL: folderURL, onCurrentURL: onCurrentURL, onSetLoading: onSetLoading 33 | ) 34 | let runId = Int64(Date().timeIntervalSince1970 * 1000) 35 | 36 | let itemsToCreate = result?.allItems.map { x in 37 | var parentPathId: Int64? = nil 38 | if let parentURL = x.parentURL { 39 | parentPathId = makeURLHash(parentURL) 40 | logger.debug( 41 | "\(x.url) is creating parent path [\(parentPathId ?? -1)]: \(parentURL.absoluteString)" 42 | ) 43 | } 44 | let pathId = makeURLHash(x.url) 45 | return SourcePath( 46 | id: nil, 47 | sourceId: sourceId, 48 | pathId: pathId, 49 | parentPathId: parentPathId, 50 | name: x.name, 51 | relativePath: x.relativePath, 52 | isDirectory: x.isDirectory, 53 | fileHashSHA256: nil, 54 | runId: runId, 55 | createdAt: Date(), 56 | updatedAt: nil 57 | ) 58 | } 59 | 60 | let items = itemsToCreate ?? [] 61 | let numberOfItemsToUpsert = items.count 62 | 63 | logger.debug("upserting \(numberOfItemsToUpsert) items") 64 | try await sourcePathRepository.batchUpsert(paths: items) 65 | try await sourcePathSearchRepository.batchUpsertIntoFTS(paths: items) 66 | 67 | logger.debug("removing stale paths...") 68 | let deletedCount = try await sourcePathRepository.deleteMany( 69 | sourceId: sourceId, excludingRunId: runId 70 | ) 71 | try await sourcePathSearchRepository.batchDeleteFTS( 72 | sourceId: sourceId, excludingRunId: runId 73 | ) 74 | logger.debug("removed \(deletedCount) stale paths") 75 | 76 | if let result = result { 77 | let totalAudioFiles = result.totalAudioFiles 78 | logger.debug("updating source \(sourceId)") 79 | if var lib = try await sourceRepository.getOne(id: sourceId) { 80 | lib.lastSyncedAt = Date() 81 | lib.updatedAt = Date() 82 | lib.totalPaths = totalAudioFiles 83 | return try await sourceRepository.updateSource(source: lib) 84 | } else { 85 | logger.error("for some reason source \(sourceId) wasn't found") 86 | } 87 | } 88 | 89 | if var lib = try await sourceRepository.getOne(id: sourceId) { 90 | lib.lastSyncedAt = Date() 91 | lib.updatedAt = Date() 92 | lib.totalPaths = result?.totalAudioFiles ?? 0 // Ensure totalPaths is set 93 | return try await sourceRepository.updateSource(source: lib) 94 | } 95 | return nil 96 | } catch { 97 | logger.error("sync dir error: \(error)") 98 | if var lib = try await sourceRepository.getOne(id: sourceId) { 99 | lib.lastSyncedAt = Date() 100 | lib.updatedAt = Date() 101 | lib.syncError = "\(error)" 102 | return try await sourceRepository.updateSource(source: lib) 103 | } 104 | return nil 105 | } 106 | } 107 | 108 | func syncDirInner( 109 | folderURL: URL, 110 | onCurrentURL: ((_ url: URL) -> Void)?, 111 | onSetLoading _: ((_ loading: Bool) -> Void)? 112 | ) async throws 113 | -> SourceSyncResult? 114 | { 115 | var audioURLs: [SourceSyncResultItem] = [] 116 | var result = [String: SourceSyncResultItem]() 117 | let bookmarkKey = makeBookmarkKey(folderURL) 118 | let audioExtensions = ["mp3", "wav", "m4a", "flac", "aac", "aiff", "aif"] 119 | guard let bookmarkData = UserDefaults.standard.data(forKey: bookmarkKey) else { 120 | throw CustomError.genericError("no bookmark found, pick folder") 121 | } 122 | 123 | var isStale = false 124 | do { 125 | let folderURL = try URL( 126 | resolvingBookmarkData: bookmarkData, 127 | options: [], 128 | relativeTo: nil, 129 | bookmarkDataIsStale: &isStale 130 | ) 131 | 132 | if isStale { 133 | // The bookmark is stale, so we need a new one 134 | throw CustomError.genericError( 135 | "Bookmark is stale, user needs to pick folder again.") 136 | } 137 | 138 | // Start accessing security-scoped resource 139 | guard folderURL.startAccessingSecurityScopedResource() else { 140 | throw CustomError.genericError("Couldn't start accessing security scoped resource.") 141 | } 142 | defer { folderURL.stopAccessingSecurityScopedResource() } 143 | 144 | // Now we can scan the folder 145 | if let enumerator = FileManager.default.enumerator( 146 | at: folderURL, includingPropertiesForKeys: [.isDirectoryKey], 147 | options: [.skipsHiddenFiles, .skipsPackageDescendants] 148 | ) { 149 | for case let file as URL in enumerator { 150 | do { 151 | onCurrentURL?(file) 152 | let resourceValues = try file.resourceValues(forKeys: [.isDirectoryKey]) 153 | if resourceValues.isDirectory == false, // file.pathExtension.lowercased() == "mp3" 154 | audioExtensions.contains(file.pathExtension.lowercased()) 155 | { 156 | let resultItem = SourceSyncResultItem( 157 | rootURL: folderURL, current: file, isDirectory: false 158 | ) 159 | audioURLs.append(resultItem) 160 | result[FileHelper(fileURL: file).toString()] = resultItem 161 | } else { 162 | let resultItem = SourceSyncResultItem( 163 | rootURL: folderURL, current: file, isDirectory: true 164 | ) 165 | result[FileHelper(fileURL: file).toString()] = resultItem 166 | } 167 | } catch { 168 | logger.error("Error reading resource values: \(error)") 169 | } 170 | } 171 | logger.debug("Total audio files found: \(audioURLs.count)") 172 | return SourceSyncResult( 173 | allItems: Array(result.values), audioFiles: audioURLs, 174 | totalAudioFiles: audioURLs.count 175 | ) 176 | } else { 177 | throw CustomError.genericError("failed to get enumerator") 178 | } 179 | } catch { 180 | throw CustomError.genericError("error resolving bookmark: \(error)") 181 | } 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /localwave/Sources/Data/Services/DefaultUserCloudService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import os 3 | import SQLite 4 | 5 | class DefaultUserCloudService: UserCloudService { 6 | let logger = Logger(subsystem: subsystem, category: "UserCloudService") 7 | func resolveCurrentICloudUser() async throws -> User? { 8 | if let icloudId = try await iCloudProvider.getCurrentICloudUserID() { 9 | logger.debug("found cloudID \(icloudId)") 10 | return try await userService.getOrCreateUser(icloudId: icloudId) 11 | } 12 | return nil 13 | } 14 | 15 | private let userService: UserService 16 | private let iCloudProvider: ICloudProvider 17 | 18 | public init(userService: UserService, iCloudProvider: ICloudProvider) { 19 | self.userService = userService 20 | self.iCloudProvider = iCloudProvider 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /localwave/Sources/Data/Services/DefaultUserService.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | import Foundation 3 | import os 4 | 5 | class DefaultUserService: UserService { 6 | let logger = Logger(subsystem: subsystem, category: "UserService") 7 | private var userRepository: UserRepository 8 | 9 | func getOrCreateUser(icloudId: Int64) async throws -> User { 10 | let logger = self.logger 11 | if let existingUser = try await userRepository.findByIcloudId(icloudId: icloudId) { 12 | let userId = existingUser.id ?? -1 13 | logger.debug("found user \(userId) with \(icloudId)") 14 | return existingUser 15 | } else { 16 | let user = User(id: nil, icloudId: icloudId) 17 | logger.debug("need to setup new user for \(icloudId)") 18 | return try await userRepository.create(user: user) 19 | } 20 | } 21 | 22 | public init(userRepository: UserRepository) { 23 | self.userRepository = userRepository 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /localwave/Sources/Domain/Models/Models.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // models 4 | struct User: Sendable { 5 | let id: Int64? 6 | let icloudId: Int64 7 | } 8 | 9 | enum SourceType: String, Codable, CaseIterable { 10 | case iCloud 11 | } 12 | 13 | struct Playlist: Identifiable, Sendable { 14 | let id: Int64? 15 | let name: String 16 | let createdAt: Date 17 | let updatedAt: Date? 18 | } 19 | 20 | struct PlaylistSong: Identifiable, Sendable { 21 | let id: Int64? 22 | let playlistId: Int64 23 | let songId: Int64 24 | let position: Int // New: For ordering 25 | } 26 | 27 | struct Source: Sendable, Identifiable { 28 | var id: Int64? 29 | var dirPath: String 30 | var pathId: Int64 31 | var userId: Int64 32 | var type: SourceType? 33 | var totalPaths: Int? 34 | var syncError: String? 35 | var isCurrent: Bool 36 | var createdAt: Date 37 | var lastSyncedAt: Date? 38 | var updatedAt: Date? 39 | 40 | var stableId: Int64 { 41 | id ?? Int64(abs(dirPath.hashValue)) 42 | } 43 | } 44 | 45 | struct SourcePath: Sendable { 46 | let id: Int64? 47 | let sourceId: Int64 48 | 49 | let pathId: Int64 50 | let parentPathId: Int64? 51 | let name: String 52 | let relativePath: String 53 | let isDirectory: Bool 54 | 55 | let fileHashSHA256: Data? 56 | let runId: Int64 57 | 58 | let createdAt: Date 59 | let updatedAt: Date? 60 | 61 | func copyWith(id: Int64?) -> SourcePath { 62 | return SourcePath( 63 | id: id, 64 | sourceId: sourceId, 65 | pathId: pathId, 66 | parentPathId: parentPathId, 67 | name: name, 68 | relativePath: relativePath, 69 | isDirectory: isDirectory, 70 | fileHashSHA256: fileHashSHA256, 71 | runId: runId, 72 | createdAt: createdAt, 73 | updatedAt: updatedAt 74 | ) 75 | } 76 | } 77 | 78 | struct Album: Identifiable, Hashable { 79 | let id: String 80 | let name: String 81 | let artist: String? 82 | let coverArtPath: String? 83 | 84 | init(name: String, artist: String?, coverArtPath: String?) { 85 | let cleanedName = name.isEmpty ? "Unknown Album" : name 86 | let cleanedArtist = artist?.isEmpty ?? true ? nil : artist 87 | 88 | id = "\(cleanedArtist ?? "Unknown Artist")-\(cleanedName)" 89 | self.name = cleanedName 90 | self.artist = cleanedArtist 91 | self.coverArtPath = coverArtPath 92 | } 93 | } 94 | 95 | // 1. Add file state tracking to Song model 96 | enum FileState: Int, Codable { 97 | case bookmarkOnly 98 | case copyPending 99 | case copied 100 | case failed 101 | } 102 | 103 | /// Example song model, no sourceId. We store all metadata ourselves. 104 | struct Song: Sendable, Identifiable, Equatable { 105 | let id: Int64? 106 | 107 | /// A unique-ish hash of (artist, title, album). 108 | let songKey: Int64 109 | 110 | let artist: String 111 | let title: String 112 | let album: String 113 | 114 | let albumArtist: String 115 | let releaseYear: Int? 116 | let discNumber: Int? 117 | 118 | // trackNumber property for album order 119 | let trackNumber: Int? 120 | let coverArtPath: String? 121 | var bookmark: Data? 122 | var pathHash: Int64 123 | 124 | /// Timestamps 125 | let createdAt: Date 126 | let updatedAt: Date? 127 | 128 | let localFilePath: String? // Path in app's Documents directory 129 | var fileState: FileState 130 | 131 | func copyWith(_ fp: String, _ st: FileState) -> Song { 132 | Song( 133 | id: id, 134 | songKey: songKey, 135 | artist: artist, 136 | title: title, 137 | album: album, 138 | albumArtist: albumArtist, 139 | releaseYear: releaseYear, 140 | discNumber: discNumber, 141 | trackNumber: trackNumber, 142 | coverArtPath: coverArtPath, 143 | bookmark: bookmark, 144 | pathHash: pathHash, 145 | createdAt: createdAt, 146 | updatedAt: updatedAt, 147 | localFilePath: fp, 148 | fileState: st 149 | ) 150 | } 151 | 152 | func copyWith(id: Int64?) -> Song { 153 | Song( 154 | id: id, 155 | songKey: songKey, 156 | artist: artist, 157 | title: title, 158 | album: album, 159 | albumArtist: albumArtist, 160 | releaseYear: releaseYear, 161 | discNumber: discNumber, 162 | trackNumber: trackNumber, 163 | coverArtPath: coverArtPath, 164 | bookmark: bookmark, 165 | pathHash: pathHash, 166 | createdAt: createdAt, 167 | updatedAt: updatedAt, 168 | localFilePath: localFilePath, 169 | fileState: fileState 170 | ) 171 | } 172 | 173 | var needsCopy: Bool { 174 | return fileState == .bookmarkOnly || fileState == .failed 175 | } 176 | 177 | static func == (lhs: Song, rhs: Song) -> Bool { 178 | return lhs.id == rhs.id 179 | } 180 | 181 | var uniqueId: Int64 { 182 | return id ?? songKey 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /localwave/Sources/Domain/Protocols/Protocols.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol PlayerPersistenceService { 4 | func getVolume() async -> Float 5 | func restore() async -> ([Song], Int, Song?)? 6 | func savePlaybackState(volume: Float, currentIndex: Int, songs: [Song]) async 7 | } 8 | 9 | protocol PlaylistRepository { 10 | func create(playlist: Playlist) async throws -> Playlist 11 | func update(playlist: Playlist) async throws -> Playlist 12 | func delete(playlistId: Int64) async throws 13 | func getAll() async throws -> [Playlist] 14 | func getOne(id: Int64) async throws -> Playlist? 15 | } 16 | 17 | protocol PlaylistSongRepository { 18 | func addSong(playlistId: Int64, songId: Int64) async throws 19 | func removeSong(playlistId: Int64, songId: Int64) async throws 20 | func getSongs(playlistId: Int64) async throws -> [Song] 21 | func reorderSongs(playlistId: Int64, newOrder: [Int64]) async throws // New 22 | } 23 | 24 | protocol SongRepository { 25 | /// Upsert a song based on its songKey (hash of artist/title/album). 26 | /// If a row with the same key exists, update it; otherwise insert new. 27 | func upsertSong(_ song: Song) async throws -> Song 28 | 29 | /// Full-text search by artist/title/album, returning at most `limit` songs, 30 | /// ordered by `bm25(...)`. 31 | func searchSongsFTS(query: String, limit: Int, offset: Int) async throws -> [Song] 32 | func totalSongCount(query: String) async throws -> Int 33 | 34 | func getAllArtists() async throws -> [String] 35 | func getAllAlbums() async throws -> [Album] 36 | 37 | func getSongs(ids: [Int64]) async -> [Song] 38 | func getSongByURL(_ url: URL) async -> Song? 39 | func updateBookmark(songId: Int64, bookmark: Data) async throws 40 | func deleteSong(songId: Int64) async throws 41 | func deleteAlbum(album: String, artist: String?) async throws 42 | func getSongsNeedingCopy() async -> [Song] 43 | func markSongForCopy(songId: Int64) async throws 44 | } 45 | 46 | protocol SourceImportService { 47 | func listItems(sourceId: Int64, parentPathId: Int64?) async throws -> [SourcePath] 48 | func search(sourceId: Int64, query: String) async throws -> [SourcePath] 49 | func deleteOne(sourceId: Int64) async throws 50 | } 51 | 52 | protocol SourcePathSearchRepository { 53 | func batchUpsertIntoFTS(paths: [SourcePath]) async throws 54 | func search(sourceId: Int64, query: String, limit: Int) async throws -> [PathSearchResult] 55 | func batchDeleteFTS(sourceId: Int64, excludingRunId: Int64) async throws 56 | func deleteAllFTS(sourceId: Int64) async throws 57 | } 58 | 59 | protocol SourceSyncService { 60 | func syncDir( 61 | sourceId: Int64, 62 | folderURL: URL, 63 | onCurrentURL: ((_ url: URL?) -> Void)?, 64 | onSetLoading: ((_ loading: Bool) -> Void)? 65 | ) async throws 66 | -> Source? 67 | } 68 | 69 | protocol SongImportService { 70 | func importPaths( 71 | paths: [SourcePath], 72 | onProgress: ((Double, URL) async -> Void)? 73 | ) async throws 74 | 75 | func cancelImport() async 76 | } 77 | 78 | protocol SourceService { 79 | func registerSourcePath(userId: Int64, path: String, type: SourceType) async throws -> Source 80 | func getCurrentSource(userId: Int64) async throws -> Source? 81 | func syncService() -> SourceSyncService 82 | func importService() -> SourceImportService 83 | func repository() -> SourceRepository 84 | } 85 | 86 | protocol SourcePathRepository { 87 | func getByParentId(sourceId: Int64, parentPathId: Int64?) async throws -> [SourcePath] 88 | func getByPathId(sourceId: Int64, pathId: Int64) async throws -> SourcePath? 89 | func create(path: SourcePath) async throws -> SourcePath 90 | func updateFileHash(pathId: Int64, fileHash: Data?) async throws 91 | func deleteMany(sourceId: Int64) async throws 92 | func getByParentId(parentId: Int64) async throws -> [SourcePath] 93 | func getByPath(relativePath: String, sourceId: Int64) async throws -> SourcePath? 94 | func batchUpsert(paths: [SourcePath]) async throws 95 | func deleteMany(sourceId: Int64, excludingRunId: Int64) async throws -> Int 96 | func deleteAllPaths(sourceId: Int64) async throws 97 | } 98 | 99 | protocol SourceRepository { 100 | func deleteSource(sourceId: Int64) async throws 101 | func create(source: Source) async throws -> Source 102 | func findOneByUserId(userId: Int64, path: String?) async throws -> [Source] 103 | func getOne(id: Int64) async throws -> Source? 104 | func updateSource(source: Source) async throws -> Source 105 | // needs to set isCurrent true to the source with userId 106 | // and for the rest of users libraries set isCurrentFalse 107 | func setCurrentSource(userId: Int64, sourceId: Int64) async throws -> Source 108 | } 109 | 110 | protocol UserRepository { 111 | func findByIcloudId(icloudId: Int64) async throws -> User? 112 | func create(user: User) async throws -> User 113 | } 114 | 115 | protocol UserService { 116 | func getOrCreateUser(icloudId: Int64) async throws -> User 117 | } 118 | 119 | protocol UserCloudService { 120 | func resolveCurrentICloudUser() async throws -> User? 121 | } 122 | 123 | protocol ICloudProvider { 124 | func getCurrentICloudUserID() async throws -> Int64? 125 | func isICloudAvailable() -> Bool 126 | } 127 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Common/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | class AppDelegate: NSObject, UIApplicationDelegate { 15 | let logger = Logger(subsystem: subsystem, category: "AppDelegate") 16 | func application( 17 | _: UIApplication, 18 | didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil 19 | ) -> Bool { 20 | do { 21 | try AVAudioSession.sharedInstance().setCategory(.playback) 22 | try AVAudioSession.sharedInstance().setActive(true) 23 | } catch { 24 | logger.error("Audio session setup error: \(error)") 25 | } 26 | return true 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Common/ErrorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorView.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | struct ErrorView: View { 15 | let error: String 16 | 17 | var body: some View { 18 | VStack { 19 | Text("Initialization Error") 20 | .font(.title) 21 | Text(error) 22 | .foregroundColor(.red) 23 | .padding() 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Common/ThemeProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThemeProvider.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | import AVFoundation 8 | import Combine 9 | import MediaPlayer 10 | import os 11 | import SwiftUI 12 | 13 | func Oxanium(_ size: CGFloat = 16) -> Font { 14 | return Font.custom("Oxanium", size: size) 15 | } 16 | 17 | struct ThemeProvider: ViewModifier { 18 | func body(content: Content) -> some View { 19 | content 20 | // .environment(\.font, .system(size: 18, weight: .medium)) // Global font 21 | .font(Oxanium()) 22 | .accentColor(.purple) 23 | .environment(\.colorScheme, .dark) 24 | .preferredColorScheme(.dark) // Force dark mode 25 | } 26 | } 27 | 28 | extension View { 29 | func applyTheme() -> some View { 30 | modifier(ThemeProvider()) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Common/coverArt.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | func coverArt(of song: Song) -> UIImage? { 4 | guard let coverArtPath = song.coverArtPath else { return nil } 5 | let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! 6 | let coverURL = docs.appendingPathComponent(coverArtPath) 7 | return UIImage(contentsOfFile: coverURL.path) 8 | } 9 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Library/ViewModels/AlbumListViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlbumListViewModel.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | @MainActor 15 | class AlbumListViewModel: ObservableObject { 16 | @Published var albums: [Album] = [] 17 | @Published var searchQuery = "" 18 | private let songRepo: SongRepository 19 | 20 | init(songRepo: SongRepository) { 21 | self.songRepo = songRepo 22 | } 23 | 24 | func loadAlbums() async throws { 25 | albums = try await songRepo.getAllAlbums() 26 | } 27 | 28 | var filteredAlbums: [Album] { 29 | guard !searchQuery.isEmpty else { return albums } 30 | return albums.filter { 31 | $0.name.localizedCaseInsensitiveContains(searchQuery) 32 | || $0.artist?.localizedCaseInsensitiveContains(searchQuery) ?? false 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Library/ViewModels/ArtistListViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArtistListViewModel.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | @MainActor 15 | class ArtistListViewModel: ObservableObject { 16 | @Published var artists: [String] = [] 17 | @Published var searchQuery = "" 18 | private let songRepo: SongRepository 19 | 20 | init(songRepo: SongRepository) { 21 | self.songRepo = songRepo 22 | } 23 | 24 | func loadArtists() async throws { 25 | artists = try await songRepo.getAllArtists() 26 | } 27 | 28 | var filteredArtists: [String] { 29 | guard !searchQuery.isEmpty else { return artists } 30 | return artists.filter { $0.localizedCaseInsensitiveContains(searchQuery) } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Library/ViewModels/SongListViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SongListViewModel.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | @MainActor 15 | class SongListViewModel: ObservableObject { 16 | enum Filter { 17 | case all 18 | case artist(String) 19 | case album(String, artist: String?) 20 | } 21 | 22 | private let filter: Filter 23 | private let songRepo: SongRepository 24 | @Published var songs: [Song] = [] 25 | @Published var totalSongs: Int = 0 26 | @Published var isLoadingPage: Bool = false 27 | 28 | private var currentPage: Int = 0 29 | private let pageSize: Int = 50 30 | private var hasMorePages: Bool = true 31 | private var currentQuery: String = "" 32 | 33 | private let logger = Logger( 34 | subsystem: "com.snowbear.localwave", 35 | category: "SongListViewModel" 36 | ) 37 | 38 | init(songRepo: SongRepository, filter: Filter) { 39 | self.songRepo = songRepo 40 | self.filter = filter 41 | } 42 | 43 | func reset() { 44 | currentPage = 0 45 | songs = [] 46 | hasMorePages = true 47 | } 48 | 49 | private func loadFilteredSongs() async throws -> [Song] { 50 | switch filter { 51 | case .all: 52 | return try await songRepo.searchSongsFTS( 53 | query: currentQuery, 54 | limit: pageSize, 55 | offset: currentPage * pageSize 56 | ) 57 | 58 | case let .artist(artist): 59 | let artistFilter = "artist:\"\(artist)\"" 60 | let combinedQuery = 61 | currentQuery.isEmpty ? artistFilter : "\(currentQuery) \(artistFilter)" 62 | return try await songRepo.searchSongsFTS( 63 | query: combinedQuery, 64 | limit: pageSize, 65 | offset: currentPage * pageSize 66 | ) 67 | 68 | case let .album(album, _): 69 | let albumFilter = "album:\"\(album)\"" 70 | let artistFilter = "" 71 | 72 | let combinedQuery: String 73 | if currentQuery.isEmpty { 74 | combinedQuery = [albumFilter, artistFilter].filter { !$0.isEmpty }.joined( 75 | separator: " ") 76 | } else { 77 | combinedQuery = "\(currentQuery) \(albumFilter) \(artistFilter)" 78 | } 79 | 80 | let cleanedQuery = 81 | combinedQuery 82 | .components(separatedBy: .whitespaces) 83 | .filter { !$0.isEmpty } 84 | .joined(separator: " ") 85 | 86 | logger.debug("Album query: \(cleanedQuery)") 87 | 88 | var results = try await songRepo.searchSongsFTS( 89 | query: cleanedQuery, 90 | limit: pageSize, 91 | offset: currentPage * pageSize 92 | ) 93 | 94 | if !results.isEmpty { 95 | results.sort { 96 | if $0.trackNumber == $1.trackNumber { 97 | return $0.artist.count < $1.artist.count 98 | } 99 | return ($0.trackNumber ?? Int.max) < ($1.trackNumber ?? Int.max) 100 | } 101 | } 102 | logger.debug("Returning \(results.count) songs for album \(album)") 103 | return results 104 | } 105 | } 106 | 107 | private func loadTotalSongs() async { 108 | do { 109 | let count = try await songRepo.totalSongCount(query: currentQuery) 110 | totalSongs = count 111 | logger.debug("Total songs loaded: \(count)") 112 | } catch { 113 | logger.error("Failed to load total song count: \(error.localizedDescription)") 114 | totalSongs = 0 115 | } 116 | } 117 | 118 | func loadMoreIfNeeded(currentSong song: Song) { 119 | if let index = songs.firstIndex(where: { $0.id == song.id }), index >= songs.count - 5 { 120 | Task { await loadMoreSongs() } 121 | } 122 | } 123 | 124 | func loadMoreSongs() async { 125 | guard !isLoadingPage && hasMorePages else { return } 126 | isLoadingPage = true 127 | defer { isLoadingPage = false } 128 | 129 | do { 130 | let newSongs = try await loadFilteredSongs() 131 | let received = newSongs.count 132 | 133 | hasMorePages = received >= pageSize 134 | 135 | if case .album = filter { 136 | logger.debug("need to load \(newSongs.count) songs and sort them") 137 | songs.append(contentsOf: newSongs) 138 | songs.sort { s1, s2 in 139 | if let t1 = s1.trackNumber, let t2 = s2.trackNumber { 140 | return t1 < t2 141 | } 142 | return s1.title.localizedStandardCompare(s2.title) == .orderedAscending 143 | } 144 | } else { 145 | songs.append(contentsOf: newSongs) 146 | } 147 | 148 | if received > 0 { 149 | currentPage += 1 150 | } 151 | } catch { 152 | logger.error("Error loading more songs: \(error.localizedDescription)") 153 | hasMorePages = false 154 | } 155 | } 156 | 157 | func searchSongs(query: String) async { 158 | currentQuery = query 159 | reset() 160 | await loadTotalSongs() 161 | 162 | do { 163 | let newSongs = try await loadFilteredSongs() 164 | if case .album = filter { 165 | songs = newSongs.sorted { s1, s2 in 166 | if let t1 = s1.trackNumber, let t2 = s2.trackNumber { 167 | return t1 < t2 168 | } 169 | return s1.title.localizedStandardCompare(s2.title) == .orderedAscending 170 | } 171 | } else { 172 | songs = newSongs 173 | } 174 | if newSongs.count < pageSize { 175 | hasMorePages = false 176 | } else { 177 | currentPage = 1 178 | } 179 | hasMorePages = newSongs.count >= pageSize 180 | } catch { 181 | logger.error("Search error: \(error.localizedDescription)") 182 | } 183 | } 184 | 185 | func loadInitialSongs() async { 186 | reset() 187 | do { 188 | let initialSongs = try await loadFilteredSongs() 189 | songs = initialSongs // Overwrite the array 190 | if case .album = filter { 191 | songs = initialSongs.sorted { s1, s2 in 192 | if let t1 = s1.trackNumber, let t2 = s2.trackNumber { 193 | return t1 < t2 194 | } 195 | return s1.title.localizedStandardCompare(s2.title) == .orderedAscending 196 | } 197 | } else { 198 | songs = initialSongs 199 | } 200 | if initialSongs.count == pageSize { 201 | hasMorePages = true 202 | currentPage = 1 203 | } 204 | hasMorePages = initialSongs.count >= pageSize 205 | await loadTotalSongs() 206 | } catch { 207 | logger.error("Initial load error: \(error.localizedDescription)") 208 | hasMorePages = false 209 | } 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Library/Views/AlbumCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlbumCell.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | struct AlbumCell: View { 15 | let album: Album 16 | @State private var artwork: UIImage? 17 | @EnvironmentObject private var dependencies: DependencyContainer 18 | 19 | var body: some View { 20 | VStack(alignment: .leading) { 21 | ZStack { 22 | if let artwork = artwork { 23 | Image(uiImage: artwork) 24 | .resizable() 25 | .aspectRatio(1, contentMode: .fill) 26 | } else { 27 | Image(systemName: "music.note") 28 | .resizable() 29 | .aspectRatio(1, contentMode: .fit) 30 | .padding() 31 | .background(Color.gray.opacity(0.3)) 32 | } 33 | } 34 | .frame(width: 160, height: 160) 35 | .cornerRadius(8) 36 | .clipped() 37 | 38 | VStack(alignment: .leading) { 39 | Text(album.name) 40 | .font(.subheadline) 41 | .lineLimit(1) 42 | Text(album.artist ?? "Unknown Artist") 43 | .font(.caption) 44 | .foregroundColor(.secondary) 45 | .lineLimit(1) 46 | } 47 | .frame(width: 160) 48 | } 49 | .onAppear { 50 | loadArtwork() 51 | } 52 | .contextMenu { 53 | Button("Delete Album", role: .destructive) { 54 | Task { 55 | try? await dependencies.songRepository.deleteAlbum( 56 | album: album.name, artist: album.artist 57 | ) 58 | NotificationCenter.default.post( 59 | name: Notification.Name("AlbumListRefresh"), object: nil 60 | ) 61 | } 62 | } 63 | } 64 | } 65 | 66 | private func loadArtwork() { 67 | guard let path = album.coverArtPath else { return } 68 | let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! 69 | let url = docs.appendingPathComponent(path) 70 | 71 | DispatchQueue.global(qos: .userInitiated).async { 72 | if let image = UIImage(contentsOfFile: url.path) { 73 | DispatchQueue.main.async { 74 | self.artwork = image 75 | } 76 | } 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Library/Views/AlbumGridView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlbumGridView.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | struct AlbumGridView: View { 15 | @ObservedObject var viewModel: AlbumListViewModel 16 | private let columns = [GridItem(.adaptive(minimum: 160))] 17 | private let songRepo: SongRepository 18 | 19 | init(dependencies: DependencyContainer, viewModel: AlbumListViewModel) { 20 | self.viewModel = viewModel 21 | songRepo = dependencies.songRepository 22 | } 23 | 24 | var body: some View { 25 | ScrollView { 26 | SearchBar( 27 | text: $viewModel.searchQuery, 28 | onChange: { _ in }, 29 | placeholder: "Search albums...", 30 | debounceSeconds: 0.3 31 | ) 32 | if viewModel.filteredAlbums.isEmpty { 33 | emptyStateView 34 | } else { 35 | LazyVGrid(columns: columns, spacing: 20) { 36 | ForEach(viewModel.filteredAlbums) { album in 37 | NavigationLink { 38 | AlbumSongListView(album: album, songRepo: songRepo) 39 | } label: { 40 | AlbumCell(album: album) 41 | } 42 | .buttonStyle(PlainButtonStyle()) 43 | } 44 | } 45 | .padding() 46 | } 47 | } 48 | .onReceive(NotificationCenter.default.publisher(for: Notification.Name("AlbumListRefresh"))) { _ in 49 | Task { try? await viewModel.loadAlbums() } 50 | } 51 | } 52 | 53 | private var emptyStateView: some View { 54 | VStack(spacing: 20) { 55 | Image(systemName: "square.stack.slash") 56 | .font(.system(size: 60)) 57 | .foregroundColor(.secondary) 58 | 59 | Text("No Albums Found") 60 | .font(.title2) 61 | 62 | Text("Add a music source with audio files to populate albums") 63 | .multilineTextAlignment(.center) 64 | .foregroundColor(.secondary) 65 | } 66 | .padding() 67 | .frame(maxHeight: .infinity) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Library/Views/AlbumSongListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlbumSongListView.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | struct AlbumSongListView: View { 15 | let album: Album 16 | @StateObject private var viewModel: SongListViewModel 17 | 18 | init(album: Album, songRepo: SongRepository) { 19 | self.album = album 20 | _viewModel = StateObject( 21 | wrappedValue: SongListViewModel( 22 | songRepo: songRepo, 23 | filter: .album(album.name, artist: album.artist) 24 | ) 25 | ) 26 | } 27 | 28 | var body: some View { 29 | SongListView(viewModel: viewModel) 30 | .navigationTitle(album.name) 31 | .onAppear { 32 | Task { await viewModel.loadInitialSongs() } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Library/Views/ArtistListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArtistListView.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | struct ArtistListView: View { 15 | @ObservedObject var viewModel: ArtistListViewModel 16 | private let songRepo: SongRepository 17 | 18 | init(dependencies: DependencyContainer, viewModel: ArtistListViewModel) { 19 | songRepo = dependencies.songRepository 20 | self.viewModel = viewModel 21 | } 22 | 23 | var body: some View { 24 | VStack { 25 | if viewModel.filteredArtists.isEmpty { 26 | emptyStateView 27 | } else { 28 | SearchBar( 29 | text: $viewModel.searchQuery, 30 | onChange: { _ in }, 31 | placeholder: "Search artists...", 32 | debounceSeconds: 0.3 33 | ) 34 | 35 | List(viewModel.filteredArtists, id: \.self) { artist in 36 | NavigationLink { 37 | ArtistSongListView(artist: artist, songRepo: songRepo) 38 | } label: { 39 | Text(artist) 40 | .font(.headline) 41 | .padding(.vertical, 8) 42 | } 43 | } 44 | .listStyle(.plain) 45 | } 46 | } 47 | } 48 | 49 | private var emptyStateView: some View { 50 | VStack(spacing: 20) { 51 | Image(systemName: "person.2.slash") 52 | .font(.system(size: 60)) 53 | .foregroundColor(.secondary) 54 | 55 | Text("No Artists Found") 56 | .font(.title2) 57 | 58 | Text("Add a music source with audio files to populate artists") 59 | .multilineTextAlignment(.center) 60 | .foregroundColor(.secondary) 61 | } 62 | .padding() 63 | .frame(maxHeight: .infinity) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Library/Views/ArtistSongListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArtistSongListView.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | struct ArtistSongListView: View { 15 | let artist: String 16 | @StateObject private var viewModel: SongListViewModel 17 | private var songRepo: SongRepository 18 | 19 | init(artist: String, songRepo: SongRepository) { 20 | self.artist = artist 21 | self.songRepo = songRepo 22 | _viewModel = StateObject( 23 | wrappedValue: SongListViewModel( 24 | songRepo: songRepo, 25 | filter: .artist(artist) 26 | ) 27 | ) 28 | } 29 | 30 | var body: some View { 31 | SongListView(viewModel: viewModel) 32 | .navigationTitle(artist) 33 | .onAppear { 34 | Task { await viewModel.loadInitialSongs() } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Library/Views/LibraryNavigation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibraryNavigation.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | import AVFoundation 8 | import Combine 9 | import MediaPlayer 10 | import os 11 | import SwiftUI 12 | 13 | class LibraryNavigation: ObservableObject { 14 | @Published var path = NavigationPath() 15 | } 16 | 17 | struct LibraryView: View { 18 | let logger = Logger(subsystem: subsystem, category: "LibraryView") 19 | 20 | @EnvironmentObject private var dependencies: DependencyContainer 21 | @EnvironmentObject private var libraryNavigation: LibraryNavigation 22 | @StateObject private var artistVM: ArtistListViewModel 23 | @StateObject private var albumVM: AlbumListViewModel 24 | @StateObject private var songListVM: SongListViewModel 25 | @EnvironmentObject private var tabState: TabState // already exists 26 | 27 | init(dependencies: DependencyContainer) { 28 | let dc = dependencies 29 | _artistVM = StateObject(wrappedValue: dc.makeArtistListViewModel()) 30 | _albumVM = StateObject(wrappedValue: dc.makeAlbumListViewModel()) 31 | _songListVM = StateObject(wrappedValue: dc.makeSongListViewModel(filter: .all)) 32 | } 33 | 34 | var body: some View { 35 | NavigationStack(path: $libraryNavigation.path) { 36 | List { 37 | NavigationLink( 38 | "Playlists", 39 | destination: PlaylistListView( 40 | viewModel: dependencies.makePlaylistListViewModel()) 41 | ) 42 | NavigationLink( 43 | "Artists", 44 | destination: ArtistListView(dependencies: dependencies, viewModel: artistVM) 45 | ) 46 | NavigationLink( 47 | "Albums", 48 | destination: AlbumGridView(dependencies: dependencies, viewModel: albumVM) 49 | ) 50 | NavigationLink("Songs", destination: SongListView(viewModel: songListVM)) 51 | } 52 | .navigationTitle("Library") 53 | } 54 | .onAppear { 55 | Task { 56 | try? await artistVM.loadArtists() 57 | try? await albumVM.loadAlbums() 58 | } 59 | } 60 | 61 | .onChange(of: tabState.selectedTab) { newTab, _ in 62 | if newTab == 0 { // library tab 63 | Task { 64 | do { 65 | try await artistVM.loadArtists() 66 | try await albumVM.loadAlbums() 67 | } catch { 68 | logger.error("failed to resync view \(error)") 69 | } 70 | } 71 | } 72 | } 73 | 74 | .onReceive(NotificationCenter.default.publisher(for: Notification.Name("LibraryRefresh"))) { 75 | _ in 76 | Task { 77 | do { 78 | try await artistVM.loadArtists() 79 | try await albumVM.loadAlbums() 80 | } catch { 81 | logger.error("failed to resync view \(error)") 82 | } 83 | } 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Library/Views/SongListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SongListView.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | struct SongListView: View { 15 | @EnvironmentObject private var tabState: TabState 16 | @EnvironmentObject private var dependencies: DependencyContainer 17 | 18 | @ObservedObject private var viewModel: SongListViewModel 19 | @State private var searchText: String = "" 20 | @State private var isPlayerPresented: Bool = false 21 | @EnvironmentObject private var playerVM: PlayerViewModel 22 | @State private var songToEdit: Song? = nil 23 | 24 | @State private var showingPlaylistSelection = false 25 | @State private var songForPlaylist: Song? = nil 26 | 27 | private let logger = Logger(subsystem: subsystem, category: "SongListView") 28 | 29 | init(viewModel: SongListViewModel) { 30 | self.viewModel = viewModel 31 | } 32 | 33 | var body: some View { 34 | return VStack { 35 | SearchBar( 36 | text: $searchText, 37 | onChange: { newValue in 38 | Task { await viewModel.searchSongs(query: newValue) } 39 | }, 40 | placeholder: "Search songs...", 41 | debounceSeconds: 0.3 42 | ) 43 | .padding() 44 | 45 | Text("Total songs: \(viewModel.totalSongs)") 46 | .font(.caption) 47 | .padding(.horizontal) 48 | 49 | if viewModel.songs.isEmpty && !viewModel.isLoadingPage { 50 | emptyStateView 51 | } else { 52 | List { 53 | ForEach(Array(viewModel.songs.enumerated()), id: \.element.uniqueId) { 54 | index, song in 55 | SongRow( 56 | song: song, 57 | onPlay: { 58 | // Populate the player queue and play the tapped song 59 | playerVM.configureQueue( 60 | songs: viewModel.songs, startIndex: index 61 | ) 62 | playerVM.playSong(song) 63 | }, 64 | onDelete: { 65 | Task { 66 | if let songId = song.id { 67 | try? await dependencies.songRepository.deleteSong( 68 | songId: songId) 69 | await viewModel.loadInitialSongs() 70 | } 71 | } 72 | }, 73 | onAddToPlaylist: { 74 | songForPlaylist = song 75 | showingPlaylistSelection = true 76 | }, 77 | onEditMetadata: { 78 | songToEdit = song 79 | }, 80 | onAddToQueue: { 81 | playerVM.addToQueue(song) 82 | } 83 | ) 84 | .onAppear { 85 | viewModel.loadMoreIfNeeded(currentSong: song) 86 | } 87 | } 88 | if viewModel.isLoadingPage { 89 | HStack { 90 | Spacer() 91 | ProgressView() 92 | Spacer() 93 | } 94 | } 95 | } 96 | .listStyle(PlainListStyle()) 97 | } 98 | } 99 | .onAppear { 100 | Task { 101 | if searchText.isEmpty { 102 | await viewModel.loadInitialSongs() 103 | } else { 104 | await viewModel.searchSongs(query: searchText) 105 | } 106 | } 107 | } 108 | .onReceive(NotificationCenter.default.publisher(for: Notification.Name("SongListRefresh"))) { _ in 109 | Task { await viewModel.loadInitialSongs() } 110 | } 111 | .onDisappear { 112 | viewModel.reset() // Clears the songs array and resets pagination. 113 | } 114 | .sheet(isPresented: $showingPlaylistSelection) { 115 | if let song = songForPlaylist { 116 | PlaylistSelectionView( 117 | song: song, 118 | songRepo: dependencies.songRepository, 119 | playlistRepo: dependencies.playlistRepo, 120 | playlistSongRepo: dependencies.playlistSongRepo 121 | ) 122 | } 123 | } 124 | .sheet(item: $songToEdit) { song in 125 | SongMetadataEditorView(song: song, songRepo: dependencies.songRepository) 126 | } 127 | } 128 | 129 | private var emptyStateView: some View { 130 | VStack(spacing: 20) { 131 | Image(systemName: "music.note.list") 132 | .font(.system(size: 60)) 133 | .foregroundColor(.secondary) 134 | 135 | Text("No Songs Found") 136 | .font(.title2) 137 | 138 | Text( 139 | searchText.isEmpty 140 | ? "Add a music source to get started" 141 | : "No matches found for '\(searchText)'" 142 | ) 143 | .multilineTextAlignment(.center) 144 | .foregroundColor(.secondary) 145 | 146 | if searchText.isEmpty { 147 | Button("Add Source") { 148 | tabState.selectedTab = 1 149 | } 150 | .buttonStyle(.borderedProminent) 151 | } 152 | } 153 | .padding() 154 | .frame(maxHeight: .infinity) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Library/Views/SongMetadataEditorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SongMetadataEditorView.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | struct SongMetadataEditorView: View { 15 | @Environment(\.dismiss) var dismiss 16 | let song: Song 17 | let songRepo: SongRepository 18 | @State private var title: String 19 | @State private var artist: String 20 | @State private var album: String 21 | @State private var albumArtist: String 22 | @State private var releaseYear: String 23 | @State private var discNumber: String 24 | @State private var trackNumber: String 25 | 26 | private let logger = Logger(subsystem: subsystem, category: "SongMetadataEditorView") 27 | 28 | init(song: Song, songRepo: SongRepository) { 29 | self.song = song 30 | self.songRepo = songRepo 31 | _title = State(initialValue: song.title) 32 | _artist = State(initialValue: song.artist) 33 | _album = State(initialValue: song.album) 34 | _albumArtist = State(initialValue: song.albumArtist) 35 | _releaseYear = State(initialValue: song.releaseYear != nil ? "\(song.releaseYear!)" : "") 36 | _discNumber = State(initialValue: song.discNumber != nil ? "\(song.discNumber!)" : "") 37 | _trackNumber = State(initialValue: song.trackNumber != nil ? "\(song.trackNumber!)" : "") 38 | } 39 | 40 | var body: some View { 41 | NavigationStack { 42 | Form { 43 | Section(header: Text("Basic Info")) { 44 | TextField("Title", text: $title) 45 | TextField("Artist", text: $artist) 46 | TextField("Album", text: $album) 47 | TextField("Album Artist", text: $albumArtist) 48 | } 49 | Section(header: Text("Additional Info")) { 50 | TextField("Release Year", text: $releaseYear) 51 | .keyboardType(.numberPad) 52 | TextField("Disc Number", text: $discNumber) 53 | .keyboardType(.numberPad) 54 | TextField("Track Number", text: $trackNumber) 55 | .keyboardType(.numberPad) 56 | } 57 | } 58 | .navigationTitle("Edit Metadata") 59 | .toolbar { 60 | ToolbarItem(placement: .confirmationAction) { 61 | Button("Save") { 62 | Task { 63 | let updatedSong = Song( 64 | id: song.id, 65 | songKey: song.songKey, 66 | artist: artist, 67 | title: title, 68 | album: album, 69 | albumArtist: albumArtist, 70 | releaseYear: Int(releaseYear), 71 | discNumber: Int(discNumber), 72 | trackNumber: Int(trackNumber), 73 | coverArtPath: song.coverArtPath, 74 | bookmark: song.bookmark, 75 | pathHash: song.pathHash, 76 | createdAt: song.createdAt, 77 | updatedAt: Date(), 78 | localFilePath: song.localFilePath, 79 | fileState: song.fileState 80 | ) 81 | do { 82 | _ = try await songRepo.upsertSong(updatedSong) 83 | NotificationCenter.default.post( 84 | name: Notification.Name("SongListRefresh"), object: nil 85 | ) 86 | dismiss() 87 | } catch { 88 | logger.error("failed to upsert: \(error)") 89 | } 90 | } 91 | } 92 | } 93 | ToolbarItem(placement: .cancellationAction) { 94 | Button("Cancel") { dismiss() } 95 | } 96 | } 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Library/Views/SongRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SongRow.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | struct SongRow: View { 15 | @EnvironmentObject private var playerVM: PlayerViewModel 16 | 17 | let song: Song 18 | let onPlay: () -> Void 19 | 20 | var onDelete: (() -> Void)? = nil 21 | var onAddToPlaylist: (() -> Void)? = nil 22 | var onEditMetadata: (() -> Void)? = nil 23 | var onAddToQueue: (() -> Void)? = nil 24 | 25 | var body: some View { 26 | HStack { 27 | VStack(alignment: .leading) { 28 | Text(song.title) 29 | .font(.headline) 30 | Text(song.artist) 31 | .font(.subheadline) 32 | .foregroundColor(.secondary) 33 | } 34 | Spacer() 35 | if song.id == playerVM.currentSong?.id && playerVM.isPlaying { 36 | // This icon serves as a playing indicator. 37 | Image(systemName: "speaker.wave.2.fill") 38 | .foregroundColor(.green) 39 | } 40 | } 41 | .contentShape(Rectangle()) // Make the whole row tappable 42 | .onTapGesture { 43 | onPlay() 44 | } 45 | .padding(.vertical, 4) 46 | .contextMenu { 47 | Button("Add to Queue") { 48 | onAddToQueue?() 49 | } 50 | Button("Delete Song", role: .destructive) { 51 | onDelete?() 52 | } 53 | Button("Add to Playlist") { 54 | onAddToPlaylist?() 55 | } 56 | Button("Edit Metadata") { 57 | onEditMetadata?() 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Player/Views/MiniPlayerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MiniPlayerView.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | struct MiniPlayerView: View { 15 | @EnvironmentObject private var playerVM: PlayerViewModel 16 | var onTap: () -> Void 17 | 18 | var body: some View { 19 | MiniPlayerViewInner( 20 | currentSong: playerVM.currentSong, 21 | onTap: onTap, playPauseAction: { playerVM.playPause() }, isPlaying: playerVM.isPlaying 22 | ) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Player/Views/MiniPlayerViewInner.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MiniPlayerViewInner.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | struct MiniPlayerViewInner: View { 15 | let currentSong: Song? 16 | let onTap: () -> Void 17 | let playPauseAction: () -> Void 18 | let isPlaying: Bool 19 | 20 | var body: some View { 21 | if currentSong != nil { 22 | Button(action: { 23 | onTap() 24 | }) { 25 | HStack { 26 | if let song = currentSong, let cover = coverArt(of: song) { 27 | Image(uiImage: cover) 28 | .resizable() 29 | .frame(width: 50, height: 50) 30 | .cornerRadius(5) 31 | } else { 32 | Image(systemName: "music.note") 33 | .scaleEffect(1.6) 34 | .frame(width: 50, height: 50) 35 | .cornerRadius(5) 36 | } 37 | 38 | VStack(alignment: .leading) { 39 | Text(currentSong?.title ?? "No Song") 40 | Text( 41 | "\(currentSong?.artist ?? "Unknown") - \(currentSong?.album ?? "")" 42 | ) 43 | .font(Oxanium(14)) 44 | .foregroundColor(.secondary) 45 | } 46 | Spacer() 47 | Button(action: playPauseAction) { 48 | Image(systemName: isPlaying ? "pause.fill" : "play.fill") 49 | .font(.system(size: 24)) 50 | } 51 | .buttonStyle(PlainButtonStyle()) 52 | } 53 | .padding(.trailing, 15) 54 | .background(Color(UIColor.secondarySystemBackground)) 55 | .overlay( 56 | Rectangle() 57 | .frame(height: 0.2) // Height for the top border 58 | .foregroundColor(.secondary), 59 | alignment: .top // Align to top 60 | ) 61 | } 62 | .buttonStyle(PlainButtonStyle()) 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Player/Views/PlayerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlayerView.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | struct PlayerView: View { 15 | @EnvironmentObject private var playerVM: PlayerViewModel 16 | @State private var showingQueue = false 17 | @State private var shuffleEnabled: Bool = false 18 | @State private var repeatEnabled: Bool = false 19 | @Environment(\.dismiss) private var dismiss 20 | 21 | // State for playlist creation 22 | @State private var showingPlaylistAlert = false 23 | @State private var playlistName = "" 24 | @State private var editMode = EditMode.inactive 25 | 26 | var body: some View { 27 | ZStack { 28 | LinearGradient( 29 | gradient: Gradient(colors: [Color.purple.opacity(0.8), Color.blue.opacity(0.8)]), 30 | startPoint: .topLeading, 31 | endPoint: .bottomTrailing 32 | ) 33 | .edgesIgnoringSafeArea(.all) 34 | VStack { 35 | // Top Bar 36 | HStack { 37 | Spacer() 38 | Button(action: { dismiss() }) { 39 | Image(systemName: "xmark.circle.fill") 40 | .font(.largeTitle) 41 | .foregroundColor(.white) 42 | } 43 | } 44 | .padding(.top) 45 | // Artwork and Song Info Section 46 | if let song = playerVM.currentSong { 47 | VStack { 48 | if let cover = coverArt(of: song) { 49 | Image(uiImage: cover) 50 | .resizable() 51 | .scaledToFit() 52 | .frame(maxWidth: 300, maxHeight: 300) 53 | .cornerRadius(8) 54 | .shadow(radius: 10) 55 | } else { 56 | Image(systemName: "music.note") 57 | .resizable() 58 | .scaledToFit() 59 | .frame(width: 200, height: 200) 60 | .foregroundColor(.white) 61 | } 62 | 63 | Text(song.title) 64 | .font(.title) 65 | .fontWeight(.bold) 66 | .foregroundColor(.white) 67 | .padding(.top, 8) 68 | Text(song.artist) 69 | .font(.subheadline) 70 | .foregroundColor(.white.opacity(0.8)) 71 | } 72 | .padding() 73 | } 74 | 75 | // Playback Progress Slider 76 | VStack { 77 | Slider( 78 | value: Binding( 79 | get: { playerVM.playbackProgress }, 80 | set: { newValue in 81 | playerVM.seekByFraction(newValue) 82 | } 83 | ), 84 | in: 0 ... 1 85 | ) 86 | .accentColor(.yellow) 87 | .padding(.horizontal) 88 | 89 | HStack { 90 | Text(playerVM.currentTime) 91 | Spacer() 92 | Text(playerVM.duration) 93 | } 94 | .font(.caption) 95 | .foregroundColor(.white) 96 | .padding(.horizontal) 97 | } 98 | .padding(.vertical) 99 | 100 | // Playback Control Buttons 101 | HStack(spacing: 30) { 102 | Button(action: { 103 | shuffleEnabled.toggle() 104 | playerVM.setShuffle(shuffleEnabled) 105 | }) { 106 | Image(systemName: shuffleEnabled ? "shuffle.circle.fill" : "shuffle.circle") 107 | .font(.system(size: 30)) 108 | .foregroundColor(shuffleEnabled ? .yellow : .white) 109 | } 110 | 111 | Button(action: { playerVM.previousSong() }) { 112 | Image(systemName: "backward.fill") 113 | .font(.system(size: 30)) 114 | .foregroundColor(.white) 115 | } 116 | 117 | Button(action: { playerVM.playPause() }) { 118 | Image( 119 | systemName: playerVM.isPlaying 120 | ? "pause.circle.fill" : "play.circle.fill" 121 | ) 122 | .font(.system(size: 60)) 123 | .foregroundColor(.white) 124 | } 125 | 126 | Button(action: { playerVM.nextSong() }) { 127 | Image(systemName: "forward.fill") 128 | .font(.system(size: 30)) 129 | .foregroundColor(.white) 130 | } 131 | 132 | Button(action: { 133 | repeatEnabled.toggle() 134 | playerVM.setRepeat(repeatEnabled) 135 | }) { 136 | Image(systemName: repeatEnabled ? "repeat.circle.fill" : "repeat.circle") 137 | .font(.system(size: 30)) 138 | .foregroundColor(repeatEnabled ? .yellow : .white) 139 | } 140 | } 141 | .padding() 142 | 143 | // Volume Control - NEW: Updated slider range and styling 144 | HStack { 145 | Image(systemName: "speaker.fill") 146 | .foregroundColor(.white) 147 | Slider(value: $playerVM.volume, in: 0 ... 1) 148 | .accentColor(.yellow) 149 | Image(systemName: "speaker.wave.3.fill") 150 | .foregroundColor(.white) 151 | } 152 | .padding(.horizontal) 153 | 154 | // Queue Toggle Button 155 | Button(action: { 156 | showingQueue.toggle() 157 | }) { 158 | HStack { 159 | Image(systemName: "list.bullet") 160 | Text("Queue (\(playerVM.queue.count))") 161 | } 162 | .foregroundColor(.white) 163 | .padding(.vertical, 8) 164 | .padding(.horizontal, 16) 165 | .background(Color.black.opacity(0.3)) 166 | .cornerRadius(10) 167 | } 168 | .padding(.top) 169 | 170 | // Currently Played Queue - NEW: Added current song icon and tap gesture to play new song 171 | if showingQueue { 172 | ScrollView { 173 | VStack(alignment: .leading) { 174 | ForEach(playerVM.queue.indices, id: \.self) { index in 175 | let song = playerVM.queue[index] 176 | HStack { 177 | Text("\(index + 1).") 178 | .foregroundColor(.white) 179 | VStack(alignment: .leading) { 180 | Text(song.title) 181 | .font(.headline) 182 | .foregroundColor(.white) 183 | Text(song.artist) 184 | .font(.subheadline) 185 | .foregroundColor(.white.opacity(0.8)) 186 | } 187 | Spacer() 188 | if song.id == playerVM.currentSong?.id { 189 | Image(systemName: "speaker.wave.2.fill") 190 | .foregroundColor(.green) 191 | } 192 | } 193 | .padding(.vertical, 4) 194 | .contentShape(Rectangle()) 195 | .onTapGesture { 196 | playerVM.playSong(song) 197 | } 198 | } 199 | .onMove { indices, newOffset in 200 | playerVM.reorderQueue(from: indices, to: newOffset) 201 | } 202 | } 203 | .environment(\.editMode, $editMode) 204 | .padding() 205 | } 206 | .frame(maxHeight: 200) 207 | .background(Color.black.opacity(0.2)) 208 | .cornerRadius(8) 209 | .padding(.horizontal) 210 | .toolbar { 211 | ToolbarItemGroup(placement: .navigationBarTrailing) { 212 | Button("Save as Playlist") { 213 | showingPlaylistAlert = true 214 | } 215 | EditButton() 216 | } 217 | } 218 | .alert("New Playlist", isPresented: $showingPlaylistAlert) { 219 | TextField("Playlist Name", text: $playlistName) 220 | Button("Create") { 221 | Task { 222 | try await playerVM.createPlaylist(name: playlistName) 223 | playlistName = "" 224 | } 225 | } 226 | Button("Cancel", role: .cancel) {} 227 | } 228 | } 229 | 230 | Spacer() 231 | } 232 | .padding() 233 | } 234 | .onAppear { 235 | playerVM.updateNowPlayingInfo() 236 | } 237 | .onReceive( 238 | NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification) 239 | ) { _ in 240 | playerVM.updateNowPlayingInfo() 241 | } 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Playlists/ViewModels/PlaylistDetailViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlaylistDetailViewModel.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | @MainActor 15 | class PlaylistDetailViewModel: ObservableObject { 16 | @Published var songs: [Song] = [] 17 | @Published var showAddSongs = false 18 | @Published var selectedSongs = Set() 19 | 20 | let playlist: Playlist 21 | let playlistSongRepo: PlaylistSongRepository 22 | let songRepo: SongRepository 23 | 24 | init(playlist: Playlist, playlistSongRepo: PlaylistSongRepository, songRepo: SongRepository) { 25 | self.playlist = playlist 26 | self.playlistSongRepo = playlistSongRepo 27 | self.songRepo = songRepo 28 | } 29 | 30 | func loadSongs() async { 31 | songs = (try? await playlistSongRepo.getSongs(playlistId: playlist.id!)) ?? [] 32 | } 33 | 34 | func deleteSong(at offsets: IndexSet) async { 35 | guard let playlistId = playlist.id else { return } 36 | for index in offsets { 37 | let songId = songs[index].id! 38 | try? await playlistSongRepo.removeSong(playlistId: playlistId, songId: songId) 39 | } 40 | await loadSongs() 41 | } 42 | 43 | func reorderSongs(from source: IndexSet, to destination: Int) async { 44 | var updatedSongs = songs 45 | updatedSongs.move(fromOffsets: source, toOffset: destination) 46 | 47 | let newOrder = updatedSongs.map { $0.id! } 48 | try? await playlistSongRepo.reorderSongs(playlistId: playlist.id!, newOrder: newOrder) 49 | await loadSongs() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Playlists/ViewModels/PlaylistListViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlaylistListViewModel.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | @MainActor 15 | class PlaylistListViewModel: ObservableObject { 16 | @Published var playlists: [Playlist] = [] 17 | @Published var showingCreateDialog = false 18 | @Published var newPlaylistName = "" 19 | 20 | let playlistRepo: PlaylistRepository 21 | let playlistSongRepo: PlaylistSongRepository 22 | let songRepo: SongRepository 23 | 24 | private let logger = Logger(subsystem: subsystem, category: "PlaylistListViewModel") 25 | 26 | init( 27 | playlistRepo: PlaylistRepository, 28 | playlistSongRepo: PlaylistSongRepository, 29 | songRepo: SongRepository 30 | ) { 31 | self.playlistRepo = playlistRepo 32 | self.playlistSongRepo = playlistSongRepo 33 | self.songRepo = songRepo 34 | } 35 | 36 | func loadPlaylists() async { 37 | playlists = (try? await playlistRepo.getAll()) ?? [] 38 | } 39 | 40 | func deletePlaylist(at offsets: IndexSet) async { 41 | for index in offsets { 42 | let playlist = playlists[index] 43 | if let id = playlist.id { 44 | do { 45 | try await playlistRepo.delete(playlistId: id) 46 | } catch { 47 | // Log or handle error as needed. 48 | logger.debug("failed to delete song with id: \(id)") 49 | } 50 | } 51 | } 52 | await loadPlaylists() 53 | } 54 | 55 | func createPlaylist() async { 56 | guard !newPlaylistName.isEmpty else { return } 57 | let playlist = Playlist(id: nil, name: newPlaylistName, createdAt: Date(), updatedAt: nil) 58 | if let created = try? await playlistRepo.create(playlist: playlist) { 59 | playlists.append(created) 60 | newPlaylistName = "" 61 | showingCreateDialog = false 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Playlists/Views/PlaylistDetailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlaylistDetailView.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | struct PlaylistDetailView: View { 15 | let playlist: Playlist 16 | @ObservedObject var viewModel: PlaylistDetailViewModel 17 | @EnvironmentObject var playerVM: PlayerViewModel 18 | 19 | var body: some View { 20 | VStack { 21 | if viewModel.songs.isEmpty { 22 | VStack(spacing: 20) { 23 | Image(systemName: "music.note") 24 | .font(.system(size: 60)) 25 | .foregroundColor(.gray) 26 | Text("No Songs in this Playlist") 27 | .font(.title2) 28 | .foregroundColor(.secondary) 29 | Text("Tap 'Add Songs' to add your favorite tracks.") 30 | .multilineTextAlignment(.center) 31 | .foregroundColor(.secondary) 32 | } 33 | .padding() 34 | .frame(maxWidth: .infinity, maxHeight: .infinity) 35 | } else { 36 | List { 37 | ForEach(viewModel.songs) { song in 38 | SongRow(song: song) { 39 | if let index = viewModel.songs.firstIndex(of: song) { 40 | playerVM.configureQueue(songs: viewModel.songs, startIndex: index) 41 | playerVM.playSong(song) 42 | } 43 | } 44 | } 45 | .onDelete { offsets in 46 | Task { await viewModel.deleteSong(at: offsets) } 47 | } 48 | .onMove { from, to in 49 | Task { await viewModel.reorderSongs(from: from, to: to) } 50 | } 51 | } 52 | } 53 | } 54 | .toolbar { 55 | ToolbarItemGroup(placement: .navigationBarTrailing) { 56 | Button("Add Songs") { 57 | viewModel.showAddSongs = true 58 | } 59 | EditButton() 60 | } 61 | } 62 | .sheet(isPresented: $viewModel.showAddSongs) { 63 | SongSelectionView( 64 | songRepo: viewModel.songRepo, 65 | onSongsSelected: { selected in 66 | Task { 67 | guard let playlistId = playlist.id else { return } 68 | for song in selected { 69 | try? await viewModel.playlistSongRepo.addSong( 70 | playlistId: playlistId, 71 | songId: song.id! 72 | ) 73 | } 74 | await viewModel.loadSongs() 75 | } 76 | } 77 | ) 78 | } 79 | .navigationTitle(playlist.name) 80 | .onAppear { 81 | Task { await viewModel.loadSongs() } 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Playlists/Views/PlaylistListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlaylistListView.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | struct PlaylistListView: View { 15 | @ObservedObject var viewModel: PlaylistListViewModel 16 | 17 | var body: some View { 18 | NavigationStack { 19 | if viewModel.playlists.isEmpty { 20 | VStack(spacing: 20) { 21 | Image(systemName: "music.note.list") 22 | .font(.system(size: 60)) 23 | .foregroundColor(.gray) 24 | Text("No Playlists Found") 25 | .font(.title2) 26 | .foregroundColor(.secondary) 27 | Text("Create a playlist to get started.") 28 | .multilineTextAlignment(.center) 29 | .foregroundColor(.secondary) 30 | Button("Create Playlist") { 31 | viewModel.showingCreateDialog = true 32 | } 33 | .buttonStyle(.borderedProminent) 34 | } 35 | .padding() 36 | .navigationTitle("Playlists") 37 | } else { // OLD: Existing list view for playlists 38 | List { 39 | ForEach(viewModel.playlists) { playlist in 40 | NavigationLink { 41 | PlaylistDetailView( 42 | playlist: playlist, 43 | viewModel: PlaylistDetailViewModel( 44 | playlist: playlist, 45 | playlistSongRepo: viewModel.playlistSongRepo, 46 | songRepo: viewModel.songRepo 47 | ) 48 | ) 49 | } label: { 50 | Text(playlist.name) 51 | .font(.headline) 52 | } 53 | } 54 | .onDelete { offsets in 55 | Task { await viewModel.deletePlaylist(at: offsets) } 56 | } 57 | } 58 | .toolbar { 59 | Button { 60 | viewModel.showingCreateDialog = true 61 | } label: { 62 | Image(systemName: "plus") 63 | } 64 | } 65 | .navigationTitle("Playlists") 66 | } 67 | } 68 | .alert("New Playlist", isPresented: $viewModel.showingCreateDialog) { 69 | TextField("Name", text: $viewModel.newPlaylistName) 70 | Button("Create") { 71 | Task { await viewModel.createPlaylist() } 72 | } 73 | Button("Cancel", role: .cancel) {} 74 | } 75 | .onAppear { 76 | Task { await viewModel.loadPlaylists() } 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Playlists/Views/PlaylistSelectionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlaylistSelectionView.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | struct PlaylistSelectionView: View { 15 | let song: Song 16 | @StateObject private var viewModel: PlaylistListViewModel 17 | @Environment(\.dismiss) var dismiss 18 | 19 | init( 20 | song: Song, songRepo: SongRepository, playlistRepo: PlaylistRepository, 21 | playlistSongRepo: PlaylistSongRepository 22 | ) { 23 | self.song = song 24 | _viewModel = StateObject( 25 | wrappedValue: PlaylistListViewModel( 26 | playlistRepo: playlistRepo, 27 | playlistSongRepo: playlistSongRepo, 28 | songRepo: songRepo // Assuming access to song repo 29 | ) 30 | ) 31 | } 32 | 33 | var body: some View { 34 | NavigationStack { 35 | List(viewModel.playlists) { playlist in 36 | Button(playlist.name) { 37 | Task { 38 | guard let playlistId = playlist.id, let songId = song.id else { return } 39 | try? await viewModel.playlistSongRepo.addSong( 40 | playlistId: playlistId, 41 | songId: songId 42 | ) 43 | dismiss() 44 | } 45 | } 46 | } 47 | .navigationTitle("Select Playlist") 48 | .toolbar { 49 | ToolbarItem(placement: .cancellationAction) { 50 | Button("Cancel") { dismiss() } 51 | } 52 | } 53 | .onAppear { 54 | Task { await viewModel.loadPlaylists() } 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Playlists/Views/SelectableSongRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SelectableSongRow.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | struct SelectableSongRow: View { 15 | let song: Song 16 | let isSelected: Bool 17 | let onToggle: () -> Void 18 | 19 | var body: some View { 20 | HStack { 21 | VStack(alignment: .leading) { 22 | Text(song.title) 23 | .font(.headline) 24 | Text(song.artist) 25 | .font(.subheadline) 26 | .foregroundColor(.secondary) 27 | } 28 | Spacer() 29 | Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") 30 | .foregroundColor(isSelected ? .blue : .gray) 31 | } 32 | .contentShape(Rectangle()) 33 | .onTapGesture { 34 | onToggle() 35 | } 36 | .padding(.vertical, 4) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Playlists/Views/SongSelectionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SongSelectionView.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | struct SongSelectionView: View { 15 | let songRepo: SongRepository 16 | let onSongsSelected: ([Song]) -> Void 17 | @State private var selectedSongs = Set() 18 | @StateObject private var songListVM: SongListViewModel 19 | 20 | init(songRepo: SongRepository, onSongsSelected: @escaping ([Song]) -> Void) { 21 | self.songRepo = songRepo 22 | self.onSongsSelected = onSongsSelected 23 | _songListVM = StateObject( 24 | wrappedValue: SongListViewModel( 25 | songRepo: songRepo, 26 | filter: .all 27 | ) 28 | ) 29 | } 30 | 31 | var body: some View { 32 | NavigationStack { 33 | List(songListVM.songs, id: \.uniqueId) { song in 34 | SelectableSongRow( 35 | song: song, 36 | isSelected: selectedSongs.contains(song.id ?? -1) 37 | ) { 38 | if selectedSongs.contains(song.id ?? -1) { 39 | selectedSongs.remove(song.id ?? -1) 40 | } else { 41 | selectedSongs.insert(song.id ?? -1) 42 | } 43 | } 44 | } 45 | .toolbar { 46 | Button("Add") { 47 | let songsToAdd = songListVM.songs.filter { selectedSongs.contains($0.id ?? -1) } 48 | onSongsSelected(songsToAdd) 49 | } 50 | } 51 | .onAppear { 52 | Task { await songListVM.loadInitialSongs() } 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Shared/CustomTabView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomTabView.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | struct CustomTabView: View { 15 | @Binding var selection: Int 16 | let tabs: [TabItem] 17 | 18 | init(selection: Binding, @TabItemBuilder content: () -> [TabItem]) { 19 | _selection = selection 20 | tabs = content() 21 | } 22 | 23 | var body: some View { 24 | ZStack { 25 | ForEach(tabs.indices, id: \.self) { index in 26 | tabs[index].content 27 | .opacity(selection == index ? 1 : 0) 28 | .animation(nil, value: selection) 29 | } 30 | } 31 | .overlay( 32 | // Your custom tab bar remains the same… 33 | HStack { 34 | ForEach(tabs.indices, id: \.self) { index in 35 | Button(action: { 36 | selection = index 37 | }) { 38 | VStack(spacing: 4) { 39 | Image(systemName: tabs[index].systemImage) 40 | .font(.system(size: 22, weight: .semibold)) 41 | Text(tabs[index].label) 42 | .font(.caption) 43 | } 44 | .foregroundColor(selection == index ? .accentColor : .gray) 45 | .frame(maxWidth: .infinity) 46 | } 47 | } 48 | } 49 | .padding(.top) 50 | .frame(height: 60) 51 | .background(.thinMaterial), 52 | alignment: .bottom 53 | ) 54 | } 55 | 56 | struct TabViewContainer: View, TabViewBuilder { 57 | let tabs: [TabItem] 58 | 59 | var body: some View { 60 | EmptyView() 61 | } 62 | } 63 | 64 | static func buildBlock(_ components: TabItem...) -> TabViewContainer { 65 | return TabViewContainer(tabs: components) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Shared/MainTabView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainTabView.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | struct MainTabView: View { 15 | @EnvironmentObject private var dependencies: DependencyContainer 16 | @EnvironmentObject private var tabState: TabState 17 | @EnvironmentObject private var playerVM: PlayerViewModel 18 | @State private var isPlayerPresented = false 19 | @StateObject private var libraryNavigation = LibraryNavigation() 20 | 21 | var body: some View { 22 | ZStack(alignment: .bottom) { 23 | CustomTabView(selection: $tabState.selectedTab) { 24 | TabItem(label: "Library", systemImage: "books.vertical", tag: 0) { 25 | LibraryView(dependencies: dependencies) 26 | .environmentObject(libraryNavigation) 27 | } 28 | TabItem(label: "Sync", systemImage: "icloud.and.arrow.down", tag: 1) { 29 | SyncView(dependencies: dependencies) 30 | } 31 | } 32 | .environmentObject(tabState) 33 | .accentColor(.cyan) 34 | 35 | MiniPlayerView { 36 | isPlayerPresented = true 37 | } 38 | .padding(.bottom, 60) 39 | } 40 | .fullScreenCover(isPresented: $isPlayerPresented) { 41 | PlayerView().environmentObject(playerVM) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Shared/SearchBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchBar.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | struct SearchBar: View { 15 | @Binding var text: String 16 | 17 | @State private var isEditing = false 18 | @State private var textSubject = PassthroughSubject() 19 | 20 | var onChange: (String) -> Void 21 | let placeholder: String 22 | let debounceSeconds: Double 23 | 24 | var body: some View { 25 | HStack { 26 | TextField(placeholder, text: $text) 27 | .padding(8) 28 | .padding(.horizontal, 25) 29 | .background(Color(.systemGray6)) 30 | .cornerRadius(8) 31 | .overlay( 32 | HStack { 33 | Image(systemName: "magnifyingglass") 34 | .foregroundColor(.gray) 35 | .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) 36 | .padding(.leading, 8) 37 | if !text.isEmpty { 38 | Button(action: { 39 | self.text = "" 40 | }) { 41 | Image(systemName: "multiply.circle.fill") 42 | .foregroundColor(.gray) 43 | .padding(.trailing, 8) 44 | } 45 | } 46 | } 47 | ) 48 | .onTapGesture { 49 | self.isEditing = true 50 | } 51 | .onChange(of: text) { 52 | textSubject.send(text) 53 | } 54 | } 55 | .onReceive(textSubject.debounce(for: .seconds(debounceSeconds), scheduler: RunLoop.main)) { 56 | debouncedValue in 57 | onChange(debouncedValue) 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Shared/TabItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TabItem.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | struct TabItem: Identifiable { 15 | let id = UUID() 16 | let label: String 17 | let systemImage: String 18 | let content: AnyView 19 | let tag: Int 20 | 21 | init( 22 | label: String, systemImage: String, tag: Int, @ViewBuilder content: () -> Content 23 | ) { 24 | self.label = label 25 | self.systemImage = systemImage 26 | self.tag = tag 27 | self.content = AnyView(content()) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Shared/TabItemBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TabItemBuilder.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | @resultBuilder 15 | struct TabItemBuilder { 16 | static func buildBlock(_ components: TabItem...) -> [TabItem] { 17 | components 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Shared/TabState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TabState.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | class TabState: ObservableObject { 15 | @Published var selectedTab: Int = 0 16 | } 17 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Shared/TabViewBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TabViewBuilder.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | protocol TabViewBuilder { 15 | var tabs: [TabItem] { get } 16 | } 17 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Sync/ViewModels/SourceBrowseViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SourceBrowseViewModel.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | @MainActor 15 | class SourceBrowseViewModel: ObservableObject { 16 | private let service: SourceImportService 17 | private let sourceId: Int64 18 | @Published var isImporting = false 19 | /// A stack of visited parentPathIds; the last element is the "current folder." 20 | @Published private var pathStack: [Int64?] = [nil] 21 | 22 | @Published var items: [SourcePath] = [] 23 | @Published var searchTerm: String = "" 24 | 25 | /// For checkboxes on any item (folders or files). 26 | @Published var selectedPathIds = Set() 27 | 28 | /// The current parent path we’re displaying. 29 | var parentPathId: Int64? { 30 | pathStack.last ?? nil 31 | } 32 | 33 | /// Whether we can go back one level. 34 | var canGoBack: Bool { 35 | pathStack.count > 1 36 | } 37 | 38 | init(service: SourceImportService, sourceId: Int64, initialParentPathId: Int64? = nil) { 39 | self.service = service 40 | self.sourceId = sourceId 41 | if let initialParent = initialParentPathId { 42 | pathStack = [nil, initialParent] 43 | } 44 | } 45 | 46 | func loadItems() async { 47 | do { 48 | if searchTerm.isEmpty { 49 | items = try await service.listItems( 50 | sourceId: sourceId, parentPathId: parentPathId 51 | ) 52 | } else { 53 | items = try await service.search(sourceId: sourceId, query: searchTerm) 54 | } 55 | } catch { 56 | logger.error("Load items error: \(error)") 57 | } 58 | } 59 | 60 | let logger = Logger(subsystem: subsystem, category: "SourceBrowseViewModel") 61 | 62 | /// Navigate into a subfolder (push on stack). 63 | func goIntoFolder(with pathId: Int64) { 64 | pathStack.append(pathId) 65 | searchTerm = "" 66 | Task { await loadItems() } 67 | } 68 | 69 | /// Go up one level (pop from stack). 70 | func goBack() { 71 | guard canGoBack else { return } 72 | pathStack.removeLast() 73 | searchTerm = "" 74 | Task { await loadItems() } 75 | } 76 | 77 | /// Toggle selection for a path (folder or file). 78 | func toggleSelection(_ pathId: Int64) { 79 | if selectedPathIds.contains(pathId) { 80 | selectedPathIds.remove(pathId) 81 | } else { 82 | selectedPathIds.insert(pathId) 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Sync/Views/SourceBrowseView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SourceBrowseView.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | struct SourceBrowseView: View { 15 | let sourceId: Int64 16 | let parentPathId: Int64? 17 | let sourceImportService: SourceImportService? 18 | let songImportService: SongImportService? 19 | @StateObject var viewModel: SourceBrowseViewModel 20 | 21 | init( 22 | sourceId: Int64, 23 | parentPathId: Int64?, 24 | sourceImportService: SourceImportService?, 25 | songImportService: SongImportService?, 26 | viewModel: SourceBrowseViewModel? = nil 27 | 28 | ) { 29 | self.sourceId = sourceId 30 | self.parentPathId = parentPathId 31 | self.sourceImportService = sourceImportService 32 | self.songImportService = songImportService 33 | if let vm = viewModel { 34 | _viewModel = StateObject(wrappedValue: vm) 35 | } else { 36 | _viewModel = StateObject( 37 | wrappedValue: SourceBrowseViewModel( 38 | service: sourceImportService!, 39 | sourceId: sourceId, 40 | initialParentPathId: parentPathId 41 | )) 42 | } 43 | } 44 | 45 | var body: some View { 46 | if let service: any SourceImportService = sourceImportService, 47 | let importService = songImportService 48 | { 49 | NavigationStack { 50 | // The SourceBrowseViewInternal (which lists files/folders) remains unchanged. 51 | SourceBrowseViewInternal( 52 | sourceId: sourceId, 53 | parentPathId: parentPathId, 54 | sourceImportService: service, 55 | songImportService: importService 56 | ) 57 | } 58 | } else { 59 | Text("Services not available") 60 | .foregroundColor(.red) 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Sync/Views/SourceBrowseViewInternal.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SourceBrowseViewInternal.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | struct SourceBrowseViewInternal: View { 15 | @StateObject var viewModel: SourceBrowseViewModel 16 | @State private var showImportProgress = false 17 | @State private var importProgress: Double = 0 18 | @State private var currentFileName: String = "" 19 | let songImportService: SongImportService 20 | 21 | init( 22 | sourceId: Int64, 23 | parentPathId: Int64? = nil, 24 | sourceImportService: SourceImportService, 25 | songImportService: SongImportService 26 | ) { 27 | _viewModel = StateObject( 28 | wrappedValue: SourceBrowseViewModel( 29 | service: sourceImportService, 30 | sourceId: sourceId, 31 | initialParentPathId: parentPathId 32 | ) 33 | ) 34 | self.songImportService = songImportService 35 | } 36 | 37 | private var logger = Logger(subsystem: subsystem, category: "SourceBrowsViewInternal") 38 | var body: some View { 39 | VStack { 40 | VStack { 41 | // Top bar with optional "Back" button 42 | HStack { 43 | if viewModel.canGoBack { 44 | Button("Back") { 45 | viewModel.goBack() 46 | } 47 | .padding(.leading) 48 | } 49 | Spacer() 50 | if viewModel.selectedPathIds.count > 0 { 51 | Button( 52 | viewModel.isImporting 53 | ? "Importing..." : "Import \(viewModel.selectedPathIds.count) items" 54 | ) { 55 | guard !viewModel.isImporting else { return } 56 | 57 | Task { 58 | viewModel.isImporting = true 59 | showImportProgress = true 60 | defer { 61 | viewModel.isImporting = false 62 | showImportProgress = false 63 | NotificationCenter.default.post( 64 | name: Notification.Name("LibraryRefresh"), object: nil 65 | ) 66 | } 67 | 68 | do { 69 | let selectedPaths = viewModel.items.filter { 70 | viewModel.selectedPathIds.contains($0.pathId) 71 | } 72 | 73 | try await songImportService.importPaths( 74 | paths: selectedPaths, 75 | onProgress: { pct, fileURL in 76 | await MainActor.run { 77 | importProgress = pct 78 | currentFileName = fileURL.lastPathComponent 79 | } 80 | } 81 | ) 82 | 83 | // Clear selection only if completed successfully 84 | viewModel.selectedPathIds = [] 85 | } catch { 86 | logger.error("Import error: \(error)") 87 | // Don't clear selection if cancelled 88 | if !(error is CancellationError) { 89 | viewModel.selectedPathIds = [] 90 | } 91 | } 92 | } 93 | } 94 | .disabled(viewModel.selectedPathIds.isEmpty || viewModel.isImporting) 95 | } 96 | } 97 | if showImportProgress { 98 | VStack { 99 | if viewModel.isImporting { 100 | Text("Importing \(currentFileName) ...") 101 | ProgressView(value: importProgress, total: 100) 102 | Button("Cancel Import") { 103 | Task { 104 | await songImportService.cancelImport() 105 | showImportProgress = false 106 | } 107 | } 108 | .padding() 109 | } else { 110 | Text(importProgress >= 100 ? "Complete!" : "Cancelled") 111 | } 112 | } 113 | .padding() 114 | } 115 | 116 | SearchBar( 117 | text: $viewModel.searchTerm, 118 | onChange: { _ in 119 | Task { await viewModel.loadItems() } 120 | }, placeholder: "Search paths...", debounceSeconds: 0.1 121 | ) 122 | .padding(.horizontal) 123 | // File/Folder list 124 | List(viewModel.items, id: \.pathId) { item in 125 | HStack { 126 | // Icon: folder or doc 127 | Image(systemName: item.isDirectory ? "folder.fill" : "doc.fill") 128 | .foregroundColor(.gray) 129 | 130 | // Name + relative path 131 | VStack(alignment: .leading) { 132 | Text(item.name) 133 | .fontWeight(.medium) 134 | Text(item.relativePath) 135 | .font(.caption) 136 | .foregroundColor(.gray) 137 | .lineLimit(1) 138 | } 139 | 140 | Spacer() 141 | 142 | // Checkboxes on everything (folder or file) 143 | Button { 144 | viewModel.toggleSelection(item.pathId) 145 | } label: { 146 | Image( 147 | systemName: viewModel.selectedPathIds.contains(item.pathId) 148 | ? "checkmark.square" 149 | : "square") 150 | } 151 | .buttonStyle(BorderlessButtonStyle()) 152 | } 153 | .contentShape(Rectangle()) // Entire row is tappable 154 | .onTapGesture { 155 | if item.isDirectory { 156 | viewModel.goIntoFolder(with: item.pathId) 157 | } 158 | } 159 | } 160 | .listStyle(.plain) 161 | } 162 | .navigationTitle("Source Browser") 163 | } 164 | .onAppear { 165 | Task { await viewModel.loadItems() } 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Sync/Views/SourceGridCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SourceGridCell.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | struct SourceGridCell: View { 15 | let source: Source 16 | let isSyncing: Bool 17 | let onResync: () -> Void 18 | let onDelete: () -> Void 19 | 20 | private var lastSyncText: String { 21 | let formatter = DateFormatter() 22 | formatter.dateStyle = .short 23 | formatter.timeStyle = .short 24 | formatter.doesRelativeDateFormatting = true 25 | guard let date = source.lastSyncedAt else { return "Never synced" } 26 | return "Last sync: \(formatter.string(from: date))" 27 | } 28 | 29 | var body: some View { 30 | VStack(alignment: .leading, spacing: 8) { 31 | HStack { 32 | Image(systemName: sourceTypeIcon) 33 | .font(.title) 34 | .foregroundColor(sourceTypeColor) 35 | 36 | VStack(alignment: .leading) { 37 | Text(dirName) 38 | .font(.headline) 39 | .lineLimit(1) 40 | Text(source.dirPath) 41 | .font(.caption) 42 | .foregroundColor(.secondary) 43 | .lineLimit(1) 44 | } 45 | 46 | Spacer() 47 | 48 | if isSyncing { 49 | ProgressView() 50 | } else { 51 | Button(action: onResync) { 52 | Image(systemName: "arrow.clockwise") 53 | } 54 | } 55 | } 56 | 57 | Text(lastSyncText) 58 | .font(.caption2) 59 | .foregroundColor(.secondary) 60 | } 61 | .padding() 62 | .background(Color(.tertiarySystemBackground)) 63 | .cornerRadius(8) 64 | .contextMenu { 65 | Button(role: .destructive) { 66 | onDelete() 67 | } label: { 68 | Label("Delete Source", systemImage: "trash") 69 | } 70 | } 71 | } 72 | 73 | private var dirName: String { 74 | return makeURLFromString(source.dirPath).lastPathComponent 75 | } 76 | 77 | private var sourceTypeIcon: String { 78 | switch source.type { 79 | case .iCloud: return "icloud.fill" 80 | default: return "folder.fill" 81 | } 82 | } 83 | 84 | private var sourceTypeColor: Color { 85 | .gray 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Sync/Views/SyncView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SyncView.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import MediaPlayer 11 | import os 12 | import SwiftUI 13 | 14 | struct SyncView: View { 15 | @State private var showingFolderPicker = false 16 | private let logger = Logger(subsystem: subsystem, category: "SyncView") 17 | @StateObject private var syncViewModel: SyncViewModel 18 | private var dependencies: DependencyContainer 19 | 20 | @State private var showGrid: Bool = false 21 | 22 | init(dependencies: DependencyContainer) { 23 | _syncViewModel = StateObject(wrappedValue: dependencies.makeSyncViewModel()) 24 | self.dependencies = dependencies 25 | } 26 | 27 | var body: some View { 28 | NavigationStack { 29 | contentView 30 | .navigationTitle("Music Sources") 31 | .toolbar { 32 | // Always allow adding a source. 33 | ToolbarItem(placement: .navigationBarTrailing) { 34 | Button { 35 | showingFolderPicker = true 36 | } label: { 37 | Image(systemName: "plus") 38 | } 39 | } 40 | } 41 | .fileImporter( 42 | isPresented: $showingFolderPicker, 43 | allowedContentTypes: [.folder], 44 | allowsMultipleSelection: false 45 | ) { result in 46 | handleFolderSelection(result: result) 47 | } 48 | .onAppear { 49 | syncViewModel.loadSources() 50 | } 51 | } 52 | } 53 | 54 | @ViewBuilder 55 | private var contentView: some View { 56 | if syncViewModel.sources.isEmpty { 57 | emptyStateView 58 | } 59 | // If there is only one source and we are not forcing grid view... 60 | else if syncViewModel.sources.count == 1 && !showGrid { 61 | if let singleSource = syncViewModel.sources.first, 62 | let sourceId = singleSource.id 63 | { 64 | let browseVM = 65 | dependencies.sourceBrowseViewModels[sourceId] 66 | ?? SourceBrowseViewModel( 67 | service: syncViewModel.sourceService!.importService(), 68 | sourceId: sourceId, 69 | initialParentPathId: singleSource.pathId 70 | ) 71 | SourceBrowseView( 72 | sourceId: sourceId, 73 | parentPathId: singleSource.pathId, 74 | sourceImportService: syncViewModel.sourceService?.importService(), 75 | songImportService: syncViewModel.songImportService, 76 | viewModel: browseVM 77 | ) 78 | .onAppear { 79 | DispatchQueue.main.async { 80 | dependencies.sourceBrowseViewModels[sourceId] = browseVM 81 | } 82 | } 83 | .toolbar { 84 | ToolbarItem(placement: .navigationBarLeading) { 85 | Button("Grid") { 86 | showGrid = true 87 | } 88 | } 89 | } 90 | } 91 | } else { 92 | sourceGridView 93 | .toolbar { 94 | ToolbarItem(placement: .navigationBarLeading) { 95 | Button("Back to Grid") { 96 | showGrid = true 97 | } 98 | } 99 | } 100 | } 101 | } 102 | 103 | private var sourceGridView: some View { 104 | ScrollView { 105 | LazyVGrid(columns: [GridItem(.adaptive(minimum: 200), spacing: 20)], spacing: 20) { 106 | ForEach(syncViewModel.sources, id: \.stableId) { (source: Source) in 107 | NavigationLink { 108 | if let sourceId = source.id { 109 | let browseVM = 110 | dependencies.sourceBrowseViewModels[sourceId] 111 | ?? SourceBrowseViewModel( 112 | service: syncViewModel.sourceService!.importService(), 113 | sourceId: sourceId, 114 | initialParentPathId: source.pathId 115 | ) 116 | SourceBrowseView( 117 | sourceId: sourceId, 118 | parentPathId: source.pathId, 119 | sourceImportService: syncViewModel.sourceService?.importService(), 120 | songImportService: syncViewModel.songImportService, 121 | viewModel: browseVM 122 | ) 123 | .onAppear { 124 | DispatchQueue.main.async { 125 | dependencies.sourceBrowseViewModels[sourceId] = browseVM 126 | } 127 | } 128 | } 129 | } label: { 130 | SourceGridCell( 131 | source: source, 132 | isSyncing: syncViewModel.currentSyncSourceId == source.id, 133 | onResync: { 134 | logger.debug( 135 | "resyncing source: \(source.id ?? -1), path: \(source.dirPath)" 136 | ) 137 | syncViewModel.resyncSource(source) 138 | }, 139 | onDelete: { 140 | syncViewModel.deleteSource(source) 141 | } 142 | ) 143 | .padding() 144 | .background(Color(.secondarySystemBackground)) 145 | .cornerRadius(12) 146 | } 147 | .buttonStyle(PlainButtonStyle()) 148 | } 149 | } 150 | .padding() 151 | } 152 | } 153 | 154 | private var emptyStateView: some View { 155 | VStack(spacing: 20) { 156 | Image(systemName: "folder.badge.plus") 157 | .font(.system(size: 60)) 158 | .foregroundColor(.blue) 159 | Text("No Sources Added") 160 | .font(.title2) 161 | Text("Get started by adding your first music source from iCloud") 162 | .multilineTextAlignment(.center) 163 | .foregroundColor(.secondary) 164 | Button("Add iCloud Source") { 165 | showingFolderPicker = true 166 | } 167 | .buttonStyle(.borderedProminent) 168 | } 169 | .padding() 170 | .frame(maxHeight: .infinity) 171 | } 172 | 173 | private func handleFolderSelection(result: Result<[URL], Error>) { 174 | switch result { 175 | case let .success(urls): 176 | guard let url = urls.first else { return } 177 | do { 178 | logger.debug("new path is getting synced: \(url)") 179 | try syncViewModel.registerBookmark(url) 180 | syncViewModel.createSource(path: url.path) 181 | 182 | showGrid = true 183 | } catch { 184 | logger.error("Folder selection error: \(error.localizedDescription)") 185 | } 186 | case let .failure(error): 187 | logger.error("Folder picker error: \(error.localizedDescription)") 188 | } 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /localwave/Sources/Features/Sync/Views/SyncViewState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SyncViewState.swift 3 | // localwave 4 | // 5 | // Created by Oleg Pustovit on 20.05.2025. 6 | // 7 | import AVFoundation 8 | import Combine 9 | import MediaPlayer 10 | import os 11 | import SwiftUI 12 | 13 | enum SyncViewState { 14 | case noICloud, isInitialising, 15 | noSourceDirSet, notSyncedYet, 16 | showTreeView, syncInProgress, unboundView 17 | } 18 | 19 | @MainActor 20 | class SyncViewModel: ObservableObject { 21 | @Published var sources: [Source] = [] 22 | @Published var createdUser: User? 23 | @Published var errorMessage: String? 24 | @Published var currentSyncSourceId: Int64? 25 | 26 | @Published var selectedFolderName: String? = nil 27 | @Published var currentSource: Source? 28 | @Published var isSyncing = false 29 | @Published var currentSyncedDir: String? = nil 30 | 31 | private let userCloudService: UserCloudService? 32 | private let icloudProvider: ICloudProvider? 33 | let sourceService: SourceService? 34 | let songImportService: SongImportService? 35 | 36 | init( 37 | userCloudService: UserCloudService?, 38 | icloudProvider: ICloudProvider?, 39 | sourceService: SourceService?, 40 | songImportService: SongImportService? 41 | ) { 42 | self.userCloudService = userCloudService 43 | self.icloudProvider = icloudProvider 44 | self.sourceService = sourceService 45 | self.songImportService = songImportService 46 | } 47 | 48 | func loadSources() { 49 | Task { 50 | do { 51 | guard let currentUser = try await userCloudService?.resolveCurrentICloudUser() 52 | else { 53 | errorMessage = "User not logged in" 54 | return 55 | } 56 | 57 | sources = 58 | try await sourceService?.repository() 59 | .findOneByUserId(userId: currentUser.id ?? -1, path: nil) ?? [] 60 | } catch { 61 | errorMessage = error.localizedDescription 62 | } 63 | } 64 | } 65 | 66 | func deleteSource(_ source: Source) { 67 | Task { 68 | if let id = source.id { 69 | do { 70 | try await sourceService?.repository().deleteSource(sourceId: id) 71 | // Also, remove the source from the local list. 72 | sources.removeAll { $0.id == id } 73 | } catch { 74 | logger.error("Failed to delete source: \(error)") 75 | } 76 | } 77 | } 78 | } 79 | 80 | func createSource(path: String) { 81 | Task { 82 | do { 83 | guard let currentUser = try await userCloudService?.resolveCurrentICloudUser(), 84 | let service = sourceService 85 | else { 86 | errorMessage = "User is not available" 87 | logger.error("failed to create source: user is not available") 88 | return 89 | } 90 | 91 | let source = try await service.registerSourcePath( 92 | userId: currentUser.id ?? -1, 93 | path: path, 94 | type: .iCloud 95 | ) 96 | logger.debug("source path \(path) is registered, now syncing...") 97 | 98 | sources.append(source) 99 | try await syncSource(source) 100 | } catch let CustomError.genericError(msg) { 101 | errorMessage = msg 102 | logger.error("failed to register or sync source: \(msg)") 103 | } catch { 104 | errorMessage = error.localizedDescription 105 | logger.error("failed to register or sync source: \(error.localizedDescription)") 106 | } 107 | } 108 | } 109 | 110 | func resyncSource(_ source: Source) { 111 | Task { 112 | do { 113 | try await syncSource(source) 114 | loadSources() // Refresh list 115 | } catch let CustomError.genericError(msg) { 116 | // TODO: Need to inform user to remove this source and re-add it 117 | logger.error("loaded error: \(msg)") 118 | errorMessage = msg 119 | } catch { 120 | logger.error("loaded error: \(error.localizedDescription)") 121 | errorMessage = error.localizedDescription 122 | } 123 | } 124 | } 125 | 126 | private func syncSource(_ source: Source) async throws { 127 | currentSyncSourceId = source.id 128 | defer { 129 | currentSyncSourceId = nil 130 | NotificationCenter.default.post(name: Notification.Name("LibraryRefresh"), object: nil) 131 | } 132 | 133 | guard let sourceId = source.id else { 134 | throw CustomError.genericError("Invalid source ID") 135 | } 136 | 137 | let folderURL = try resolveSourceURL(source) 138 | let updatedSource = try await sourceService?.syncService().syncDir( 139 | sourceId: sourceId, 140 | folderURL: folderURL, 141 | onCurrentURL: { _ in }, 142 | onSetLoading: { _ in } 143 | ) 144 | 145 | if let updated = updatedSource { 146 | if let index = sources.firstIndex(where: { $0.id == source.id }) { 147 | sources[index] = updated 148 | } 149 | } 150 | } 151 | 152 | private func resolveSourceURL(_ source: Source) throws -> URL { 153 | let folderURL = makeURLFromString(source.dirPath) 154 | let bookmarkKey = makeBookmarkKey(folderURL) 155 | logger.debug("Loading bookmark key \(bookmarkKey) of \(folderURL.absoluteString)") 156 | guard let bookmarkData = UserDefaults.standard.data(forKey: bookmarkKey) else { 157 | throw CustomError.genericError("Missing bookmark data") 158 | } 159 | 160 | var isStale = false 161 | return try URL( 162 | resolvingBookmarkData: bookmarkData, 163 | options: [], 164 | relativeTo: nil, 165 | bookmarkDataIsStale: &isStale 166 | ) 167 | } 168 | 169 | var state: SyncViewState { 170 | if !hasICloud() { 171 | return .noICloud 172 | } else if hasICloud() && (createdUser == nil && errorMessage == nil) { 173 | return .isInitialising 174 | } else if createdUser != nil && currentSource == nil { 175 | return .noSourceDirSet 176 | } else if createdUser != nil && currentSource != nil && currentSource?.lastSyncedAt == nil 177 | && !isSyncing 178 | { 179 | return .notSyncedYet 180 | } else if createdUser != nil && currentSource != nil && currentSource?.lastSyncedAt != nil 181 | && !isSyncing 182 | { 183 | return .showTreeView 184 | } else if isSyncing { 185 | return .syncInProgress 186 | } 187 | 188 | return .unboundView 189 | } 190 | 191 | let logger = Logger(subsystem: subsystem, category: "SyncViewModel") 192 | 193 | func registerPath(_ path: String) { 194 | Task { 195 | logger.debug("registering \(path)") 196 | do { 197 | if let currentUser = self.createdUser { 198 | let lib = try await sourceService?.registerSourcePath( 199 | userId: currentUser.id!, path: path, type: .iCloud 200 | ) 201 | let libId = lib?.id ?? -1 202 | logger.debug("created source \(libId)") 203 | self.currentSource = lib 204 | } 205 | } catch { 206 | logger.debug("failed to register lib \(error.localizedDescription)") 207 | } 208 | 209 | logger.debug("source is set...") 210 | } 211 | } 212 | 213 | func registerBookmark(_ folderURL: URL) throws { 214 | guard folderURL.startAccessingSecurityScopedResource() else { 215 | logger.error("Unable to access security scoped resource.") 216 | return 217 | } 218 | defer { folderURL.stopAccessingSecurityScopedResource() } 219 | let bookmarkKey = makeBookmarkKey(folderURL) 220 | 221 | let bookmarkData = try folderURL.bookmarkData( 222 | options: [], 223 | includingResourceValuesForKeys: nil, 224 | relativeTo: nil 225 | ) 226 | logger.debug("Setting bookmark key \(bookmarkKey) of \(folderURL.absoluteString)") 227 | 228 | UserDefaults.standard.set(bookmarkData, forKey: bookmarkKey) 229 | } 230 | 231 | func sync() { 232 | Task { 233 | self.isSyncing = true 234 | var currentSrc = self.currentSource 235 | do { 236 | // Start syncing with updates 237 | let folderPath = currentSource?.dirPath 238 | let sourceId = currentSource?.id 239 | logger.debug("started syncing...") 240 | if folderPath != nil && sourceId != nil { 241 | let result = try await sourceService?.syncService().syncDir( 242 | sourceId: sourceId!, folderURL: makeURLFromString(folderPath!), 243 | onCurrentURL: { url in 244 | DispatchQueue.main.async { 245 | self.currentSyncedDir = url?.absoluteString 246 | } 247 | }, 248 | onSetLoading: { loading in 249 | DispatchQueue.main.async { 250 | self.isSyncing = loading 251 | } 252 | } 253 | ) 254 | currentSrc?.totalPaths = result?.totalPaths 255 | } else { 256 | logger.error("failed to sync") 257 | } 258 | self.isSyncing = false 259 | currentSrc?.lastSyncedAt = Date() 260 | currentSrc = try await sourceService?.repository().updateSource( 261 | source: currentSrc!) 262 | logger.debug("finished syncing...") 263 | self.currentSource = currentSrc 264 | } catch { 265 | self.isSyncing = false 266 | currentSrc?.lastSyncedAt = Date() 267 | currentSrc?.syncError = error.localizedDescription 268 | currentSrc = try await sourceService?.repository().updateSource( 269 | source: currentSrc!) 270 | logger.debug("finished with error") 271 | self.currentSource = currentSrc 272 | } 273 | } 274 | } 275 | 276 | func hasICloud() -> Bool { 277 | return icloudProvider?.isICloudAvailable() ?? false 278 | } 279 | 280 | func initialise() { 281 | if userCloudService == nil { 282 | errorMessage = "service is not available" 283 | } 284 | 285 | Task { @MainActor in 286 | do { 287 | let user = try await userCloudService?.resolveCurrentICloudUser() 288 | self.createdUser = user 289 | if let user = user { 290 | self.currentSource = try await sourceService?.getCurrentSource( 291 | userId: user.id!) 292 | self.selectedFolderName = self.currentSource?.dirPath 293 | let id = self.currentSource?.id ?? -1 294 | let path = self.currentSource?.dirPath ?? "" 295 | logger.debug("source \(id), path: \(path)") 296 | } 297 | } catch { 298 | self.errorMessage = error.localizedDescription 299 | } 300 | } 301 | } 302 | } 303 | -------------------------------------------------------------------------------- /localwave/localwave.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /localwaveTests/Tests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Tests.swift 3 | // musicappTests 4 | // 5 | // Created by Oleg Pustovit on 10.01.2025. 6 | // 7 | 8 | import Testing 9 | 10 | import Foundation 11 | @testable import localwave 12 | 13 | struct FileHelperTests { 14 | @Test func testToString() async throws { 15 | let url = URL(fileURLWithPath: "/Users/doggyman/Documents/myFile.txt") 16 | let helper = FileHelper(fileURL: url) 17 | #expect(helper.toString() == "file:///Users/doggyman/Documents/myFile.txt") 18 | } 19 | 20 | @Test func testName() async throws { 21 | let url = URL(fileURLWithPath: "/Users/doggyman/Documents/myFile.txt") 22 | let helper = FileHelper(fileURL: url) 23 | #expect(helper.name() == "myFile.txt") 24 | } 25 | 26 | @Test func testParent() async throws { 27 | let url = URL(fileURLWithPath: "/Users/doggyman/Documents/myFile.txt") 28 | let helper = FileHelper(fileURL: url) 29 | #expect(helper.parent()?.path == "/Users/doggyman/Documents") 30 | } 31 | 32 | @Test func testRelativePath() async throws { 33 | let baseURL = URL(fileURLWithPath: "/Users/doggyman/Documents") 34 | let fileURL = URL(fileURLWithPath: "/Users/doggyman/Documents/Folder/myFile.txt") 35 | let helper = FileHelper(fileURL: fileURL) 36 | #expect(helper.relativePath(from: baseURL) == "Folder/myFile.txt") 37 | } 38 | 39 | @Test func relativePathSamePath() async throws { 40 | let baseURL = URL(fileURLWithPath: "/Users/doggyman/Documents/Folder/myFile.txt") 41 | let fileURL = URL(fileURLWithPath: "/Users/doggyman/Documents/Folder/myFile.txt") 42 | let helper = FileHelper(fileURL: fileURL) 43 | #expect(helper.relativePath(from: baseURL) == "") 44 | } 45 | 46 | @Test func createURLWithEmptyRelativePath() async throws { 47 | let baseURL = URL(fileURLWithPath: "/Users/doggyman/Documents") 48 | let relativePath = "" 49 | let expectedURL = URL(fileURLWithPath: "/Users/doggyman/Documents") 50 | let createdURL = FileHelper.createURL(baseURL: baseURL, relativePath: relativePath) 51 | #expect(createdURL?.path == expectedURL.path) 52 | } 53 | 54 | @Test func testCreateURL() async throws { 55 | let baseURL = URL(fileURLWithPath: "/Users/doggyman/Documents") 56 | let relativePath = "Folder/myFile.txt" 57 | let expectedURL = URL(fileURLWithPath: "/Users/doggyman/Documents/Folder/myFile.txt") 58 | let createdURL = FileHelper.createURL(baseURL: baseURL, relativePath: relativePath) 59 | #expect(createdURL?.path == expectedURL.path) 60 | } 61 | } 62 | --------------------------------------------------------------------------------