├── .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 | 
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 |
--------------------------------------------------------------------------------