Changes
5 changed files (+515/-20)
-
-
@@ -17,7 +17,7 @@public struct BrowseService { public static let id = "com.roonlabs.browse:1" public enum ItemHint: LosslessStringConvertible, Codable { public enum ItemHint: LosslessStringConvertible, Codable, Hashable, Sendable { case unknown(String) case action, actionList case list
-
@@ -66,7 +66,7 @@ try value.encode(self.description)} } public struct InputPrompt: Codable { public struct InputPrompt: Codable, Hashable, Sendable { public let prompt: String public let action: String public let value: String?
-
@@ -84,7 +84,7 @@ case _isPassword = "is_password"} } public struct Item: Codable { public struct Item: Codable, Hashable, Sendable { public let title: String public let subtitle: String? public let imageKey: ImageService.Key?
-
@@ -102,7 +102,7 @@ case inputPrompt = "input_prompt"} } public enum ListHint: LosslessStringConvertible, Codable { public enum ListHint: LosslessStringConvertible, Codable, Sendable { case unknown(String) case actionList
-
@@ -137,10 +137,10 @@ try value.encode(self.description)} } public struct List: Codable { public struct List: Codable, Sendable { public let title: String public let subtitle: String? public let imageKey: ImageService.Key public let imageKey: ImageService.Key? public let displayOffset: Int? public let hint: ListHint?
-
@@ -301,7 +301,7 @@ self.multiSessionKey = multiSessionKey} } public struct LoadResponse: Codable { public struct LoadResponse: Codable, Sendable { public let items: [Item] let _offset: Int? public let list: List
-
-
-
@@ -0,0 +1,297 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import OSLog import RoonKit import SwiftUI enum BrowsePage { case loading case loaded(BrowseService.List, [BrowseService.Item]) case failed(any Error) } @MainActor @Observable final class BrowsingDataModel { @ObservationIgnored private let logger = Logger() @ObservationIgnored private var loading: Task<Void, Never>? = nil var hierarchy: BrowseService.Hierarchy? = nil { didSet { stack = [] } } var zone: TransportService.Zone? = nil private var _stack: [BrowseService.Item] = [] var stack: [BrowseService.Item] { get { return _stack } set { let oldValue = _stack _stack = newValue guard let last = _stack.last else { loadRootPage() return } if oldValue.count > _stack.count { popPages(items: Array(oldValue[_stack.count...])) return } loadPage(item: last) } } var rootPage: BrowsePage? = nil var pages: [BrowseService.Item: BrowsePage] = [:] @ObservationIgnored private let conn: Communicatable init(conn: Communicatable) { self.conn = conn } enum LoadError: Error { case hierarchyNotSet case nonListResponse } private func loadRootPage() { guard let hierarchy = hierarchy else { return } if let loading = self.loading { loading.cancel() } pages = [:] self.loading = Task { rootPage = .loading do { logger.debug("Moving browse cursor to root on \(hierarchy.rawValue)") let browse = try BrowseService.BrowseResponse( try await conn.request( Moo( browse: .init( hierarchy: hierarchy, zoneOrOutputID: zone?.id, popAll: true ) ) ) ) guard browse.action == .list else { throw LoadError.nonListResponse } logger.debug("Loading root page of \(hierarchy.rawValue)") let load = try BrowseService.LoadResponse( try await conn.request( Moo( load: .init(hierarchy: hierarchy, count: UInt(UInt16.max)) ) ) ) logger.debug("Got root page for \(hierarchy.rawValue)") rootPage = .loaded(load.list, load.items) } catch { logger.error( "Unable to load root page of \(hierarchy.rawValue): \(error)" ) rootPage = .failed(error) } } } private func popPages(items: [BrowseService.Item]) { guard let hierarchy = hierarchy else { return } if let loading = self.loading { loading.cancel() } for item in items { pages[item] = nil } let level = items.count self.loading = Task { do { logger.debug("Popping \(level) levels") let browse = try BrowseService.BrowseResponse( try await conn.request( Moo( browse: .init( hierarchy: hierarchy, zoneOrOutputID: zone?.id, popLevels: UInt(level) ) ) ) ) switch browse.action { case .list: guard let list = browse.list else { logger.warning("Roon server returned list action without payload") return } let item = stack.last do { logger.debug("Updating the new current page after pop") let load = try BrowseService.LoadResponse( try await conn.request( Moo( load: .init( hierarchy: hierarchy, level: list.level, count: UInt(UInt16.max) ) ) ) ) if let item = item { pages[item] = .loaded(load.list, load.items) } else { rootPage = .loaded(load.list, load.items) } } catch { logger.error("Unable to load topmost page after pop: \(error)") } default: // TODO: Handle other cases break } } catch { logger.error("Unable to pop pages: \(error)") } } } private func loadPage(item: BrowseService.Item) { guard let hierarchy = hierarchy else { return } if let loading = self.loading { loading.cancel() } guard let item = self.stack.last else { logger.warning("Attempt to load page ") return } self.loading = Task { pages[item] = .loading do { logger.debug( "Moving browse cursor to \(item.title)/\(item.itemKey ?? "<nil>")/\(self.stack.count)" ) let browse = try BrowseService.BrowseResponse( try await conn.request( Moo( browse: .init( hierarchy: hierarchy, itemKey: item.itemKey, zoneOrOutputID: zone?.id ) ) ) ) guard browse.action == .list else { logger.info( "Only list action is supported for now: got \(browse.action.rawValue)" ) throw LoadError.nonListResponse } guard let list = browse.list else { logger.error( "Roon server returned list action, but list property is empty" ) throw LoadError.nonListResponse } // Some items do automatically pop server stack. Clients have to guess that by // inspecting list's level and comparing that to the locally maintained stack. if list.level < _stack.count - 1 { logger.debug("Detected server pop, shrinking local stack") _stack = Array(_stack[0..<Int(list.level)]) pages[item] = nil return } logger.debug( "Loading page at \(item.title)/\(item.itemKey ?? "<nil>")/\(self.stack.count)" ) let load = try BrowseService.LoadResponse( try await conn.request( Moo( load: .init( hierarchy: hierarchy, level: list.level, count: UInt(UInt16.max) ) ) ) ) logger.debug("Got page for \(item.title)") pages[item] = .loaded(load.list, load.items) } catch { logger.error("Unable to load page: \(error)") pages[item] = .failed(error) } } } func reload() { stack = stack } }
-
-
-
@@ -17,14 +17,22 @@import RoonKit import SwiftUI @MainActor @Observable final class ZoneDataModel { @ObservationIgnored private let browsing: BrowsingDataModel? let conn: Communicatable public var zone: TransportService.Zone? = nil { didSet { if !suppressSeekUpdates, let zone = zone { seek = Float(seeks[zone.id].flatMap({ $0 }) ?? 0) } if let browsing = browsing { browsing.zone = zone } } }
-
@@ -67,8 +75,9 @@ }public var seek: Float = 0.0 init(conn: Communicatable) { init(conn: Communicatable, browsing: BrowsingDataModel? = nil) { self.conn = conn self.browsing = browsing } func watchChanges() async throws {
-
-
-
@@ -0,0 +1,97 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import OSLog import RoonKit import SwiftUI struct Browser: View { private let logger = Logger() private let model: BrowsingDataModel private let item: BrowseService.Item? private var page: BrowsePage? { if let item = item { model.pages[item] } else { model.rootPage } } var body: some View { HStack { switch page { case .none, .some(.loading): ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) .background(.background) case .some(.failed(_)): // TODO: Tidy ContentUnavailableView { Label("Load error", systemImage: "exclamationmark.triangle") } description: { Text("An error occurred when loading the page.") } actions: { Button("Retry") { model.reload() } } case .some(.loaded(let list, let items)): List(items, id: \.itemKey) { item in NavigationLink(value: item) { HStack { if let imageKey = item.imageKey { Artwork(imageKey: imageKey, width: 64, height: 64) .frame(width: 32, height: 32) } VStack(alignment: .leading) { Text(item.title) .font(.headline) .lineLimit(1) if let subtitle = item.subtitle { Text(subtitle) .font(.subheadline) .lineLimit(1) } } .frame(maxWidth: .infinity, alignment: .leading) } .padding(.vertical, 2) } } .navigationTitle(list.title) #if os(macOS) .navigationSubtitle(list.subtitle ?? "") #endif } } // As the page title can't be available until the data is loaded, // the newly created navigation stack fallbacks to the application // name ("Plac".) This causes <previous title> -> "Plac" -> <title> // sequence in a very short period, which results in glitchy-looking // title flash. To prevent this, the stack itself sets empty title // so the title "blinks" instead of "rapidly changes." .navigationTitle("") } init(model: BrowsingDataModel, item: BrowseService.Item? = nil) { self.model = model self.item = item } }
-
-
-
@@ -22,9 +22,24 @@ struct ConnectedScreen: View {private let logger = Logger() @State private var model: ZoneDataModel @State private var browsing: BrowsingDataModel @State private var hierarchies = [ BrowseService.Hierarchy.browse, BrowseService.Hierarchy.playlists, BrowseService.Hierarchy.albums, BrowseService.Hierarchy.artists, BrowseService.Hierarchy.composers, BrowseService.Hierarchy.genres, BrowseService.Hierarchy.settings, BrowseService.Hierarchy.internetRadio, ] init(conn: Communicatable & Connectable) { model = ZoneDataModel(conn: conn) let browsing = BrowsingDataModel(conn: conn) self.browsing = browsing model = ZoneDataModel(conn: conn, browsing: browsing) } private let actionQueue = DispatchQueue(
-
@@ -34,20 +49,38 @@var body: some View { VStack(spacing: 0) { NavigationSplitView { List { NavigationLink { Text("LIBRARY") } label: { Text("Library") } NavigationLink { Text("DISCOVER") } label: { Text("Discover") List(hierarchies, id: \.self, selection: $browsing.hierarchy) { hierarchy in switch hierarchy { case .browse: Text("Explore") case .playlists: Text("Playlists") case .settings: Text("Settings") case .albums: Text("Albums") case .artists: Text("Artists") case .genres: Text("Genres") case .composers: Text("Composers") case .search: Text("Search") case .internetRadio: Text("Internet Radio") } } } detail: { if browsing.hierarchy != nil { NavigationStack(path: $browsing.stack) { Browser(model: browsing) .navigationDestination(for: BrowseService.Item.self) { item in Browser(model: browsing, item: item) } } } } Divider()
-
@@ -57,6 +90,24 @@ .frame(maxWidth: .infinity).environment(model) } .task { // macOS and iPad OS renders `NavigationSplitView` with columns whereas iOS // renders stacked views. In columns layout, having no default selection or // placeholder content ("select a category") is not user-friendly. // In iPad, the situation got worse; it hides sidebar on startup in portrait // orientation, so user will see blank browsing area. Placeholder won't help // this case at all. #if os(macOS) let shouldHaveDefaultHierarchy = true #else let shouldHaveDefaultHierarchy = UIDevice.current.userInterfaceIdiom == .pad #endif if shouldHaveDefaultHierarchy { browsing.hierarchy = .browse } } .task { do { try await model.watchChanges() } catch {
-
@@ -65,3 +116,44 @@ }} } } // MARK: - Preview private enum MockError: Error { case notImplementedInMock } private actor MockServer {} extension MockServer: Communicatable { var messages: AsyncStream<Moo> { AsyncStream { stream in stream.finish() } } func request(_ msg: consuming Moo) async throws -> Moo { throw MockError.notImplementedInMock } func request(_ msg: consuming Moo, timeout: ContinuousClock.Instant.Duration) async throws -> Moo { throw MockError.notImplementedInMock } func send(_ msg: consuming Moo) async throws {} } extension MockServer: Connectable { var serverID: String { "mock-server" } var host: String { "192.0.2.1" } var port: UInt16 { 9003 } func connect() async throws {} func disconnect() {} } #Preview { ConnectedScreen(conn: MockServer()) }
-