-
1
-
2
-
3
-
4
-
5
-
6
-
7
-
8
-
9
-
10
-
11
-
12
-
13
-
14
-
15
-
16
-
17
-
18
-
19
-
20
-
21
-
22
-
23
-
24
-
25
-
26
-
27
-
28
-
29
-
30
-
31
-
32
-
33
-
34
-
35
-
36
-
37
-
38
-
39
-
40
-
41
-
42
-
43
-
44
-
45
-
46
-
47
-
48
-
49
-
50
-
51
-
52
-
53
-
54
-
55
-
56
-
57
-
58
-
59
-
60
-
61
-
62
-
63
-
64
-
65
-
66
-
67
-
68
-
69
-
70
-
71
-
72
-
73
-
74
-
75
-
76
-
77
-
78
-
79
-
80
-
81
-
82
-
83
-
84
-
85
-
86
-
87
-
88
-
89
-
90
-
91
-
92
-
93
-
94
-
95
-
96
-
97
-
98
-
99
-
100
-
101
-
102
-
103
-
104
-
105
-
106
-
107
-
108
-
109
-
110
-
111
-
112
-
113
-
114
-
115
-
116
-
117
-
118
-
119
-
120
-
121
-
122
-
123
-
124
-
125
-
126
-
127
-
128
-
129
-
130
-
131
-
132
-
133
-
134
-
135
-
136
-
137
// 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
switch item.hint {
case .list, .actionList:
NavigationLink(value: item) {
Row(item)
}
case .action:
Button {
model.loadPage(item: item)
} label: {
Row(item)
.frame(maxWidth: .infinity, alignment: .leading)
if model.pages[item]?.isLoading() ?? false {
ProgressView()
// Row height in macOS is way shorter than other platforms.
#if os(macOS)
.controlSize(.small)
#endif
}
}
.buttonStyle(.borderless)
// ".borderless" on macOS somehow renders in gray foreground color,
// which looks like the button is disabled.
#if os(macOS)
.foregroundStyle(.foreground)
#endif
.disabled(model.pages[item]?.isLoading() ?? false)
default:
Row(item)
}
}
.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
// when an item is not available (= root page of a hierarchy) so the
// title "blinks" instead of "rapidly changes."
.navigationTitle(item?.title ?? "")
}
init(model: BrowsingDataModel, item: BrowseService.Item? = nil) {
self.model = model
self.item = item
}
}
private struct Row: View {
private let item: BrowseService.Item
fileprivate init(_ item: BrowseService.Item) {
self.item = item
}
var body: some View {
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)
}
}