diff --git a/ios/Stemify/Core/Log.swift b/ios/Stemify/Core/Log.swift new file mode 100644 index 0000000..4447ac5 --- /dev/null +++ b/ios/Stemify/Core/Log.swift @@ -0,0 +1,98 @@ +import AVFoundation +import Foundation +import SwiftUI +import UIKit + +/// App log kept in a size-capped file so a tester can send it. Tokens are stripped before anything is written. +final class Log: @unchecked Sendable { + static let shared = Log() + enum Level: String { case debug = "D", info = "I", error = "E" } + + static let verboseKey = "verboseLogging" + static var verbose: Bool { + get { UserDefaults.standard.bool(forKey: verboseKey) } + set { UserDefaults.standard.set(newValue, forKey: verboseKey) } + } + + private let lock = NSLock() + private let url: URL + private let maxBytes = 512 * 1024 + private let stamp: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "MM-dd HH:mm:ss.SSS" + return f + }() + + private init() { + let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + url = dir.appendingPathComponent("stemify.log") + } + + private static let secret = try! NSRegularExpression( + pattern: #"(X-Plex-Token|X-Stem-Token|token|Authorization)([=:"' ]+)[^&\s"',}]+"#, options: [.caseInsensitive]) + + static func redact(_ s: String) -> String { + secret.stringByReplacingMatches(in: s, range: NSRange(s.startIndex..., in: s), withTemplate: "$1$2") + } + + func write(_ level: Level, _ tag: String, _ msg: String) { + if level == .debug && !Log.verbose { return } + let line = "\(stamp.string(from: Date())) \(level.rawValue) [\(tag)] \(Log.redact(msg))\n" + lock.lock(); defer { lock.unlock() } + guard let data = line.data(using: .utf8) else { return } + let size = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int) ?? 0 + if size > maxBytes { trim() } + if FileManager.default.fileExists(atPath: url.path) { append(data) } else { try? data.write(to: url) } + } + + /// Keeps the newest half of the file. + private func trim() { + guard let all = try? Data(contentsOf: url) else { return } + let tail = all.suffix(maxBytes / 2) + let cut = tail.firstIndex(of: 0x0A).map { tail.index(after: $0) } ?? tail.startIndex + try? Data(tail[cut...]).write(to: url) + } + + private func append(_ data: Data) { + guard let h = try? FileHandle(forWritingTo: url) else { return } + defer { try? h.close() } + _ = try? h.seekToEnd() + try? h.write(contentsOf: data) + } + + func text() -> String { + lock.lock(); defer { lock.unlock() } + return (try? String(contentsOf: url, encoding: .utf8)) ?? "" + } + + func clear() { + lock.lock(); defer { lock.unlock() } + try? FileManager.default.removeItem(at: url) + } + + /// Device and app details followed by the log, written to a temp file for the share sheet. + func export() -> URL { + let b = Bundle.main + let d = UIDevice.current + var head = "Stemify \(b.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?") " + head += "(\(b.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?"))\n" + head += "\(d.model) · \(d.systemName) \(d.systemVersion) · \(UIScreen.main.maximumFramesPerSecond) Hz\n" + head += "Verbose logging: \(Log.verbose ? "on" : "off")\n" + head += "Output: \(AirRoute.describe())\n\n" + let out = FileManager.default.temporaryDirectory.appendingPathComponent("stemify-log.txt") + try? (head + text()).write(to: out, atomically: true, encoding: .utf8) + return out + } + + func debug(_ tag: String, _ msg: @autoclosure () -> String) { if Log.verbose { write(.debug, tag, msg()) } } + func info(_ tag: String, _ msg: String) { write(.info, tag, msg) } + func error(_ tag: String, _ msg: String) { write(.error, tag, msg) } +} + +/// Where audio is currently going (speaker, AirPlay, Bluetooth…). +enum AirRoute { + static func describe() -> String { + let outs = AVAudioSession.sharedInstance().currentRoute.outputs.map { "\($0.portName) (\($0.portType.rawValue))" } + return outs.isEmpty ? "none" : outs.joined(separator: ", ") + } +} diff --git a/ios/Stemify/Core/Settings.swift b/ios/Stemify/Core/Settings.swift index c7a5c1b..8c2d91a 100644 --- a/ios/Stemify/Core/Settings.swift +++ b/ios/Stemify/Core/Settings.swift @@ -102,7 +102,7 @@ final class Settings: @unchecked Sendable { private static let keys = [ "plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle", "stemUrl", "stemToken", "stemRoots", "theme", "accent", "albumColorNowPlaying", "highRefreshRate", - "stemQuality", "cacheLimitGb", "downloadsWifiOnly", "downloadSort", "keepStemMix", "masterVolume", + "stemQuality", "cacheLimitGb", "downloadsWifiOnly", "downloadSort", "keepStemMix", "masterVolume", "tabLayout", ] private func read(_ kp: KeyPath, _ key: String, _ def: T) -> T { @@ -175,6 +175,7 @@ final class Settings: @unchecked Sendable { /// Plex path prefixes that matched the stems index, newline-separated. var stemRoots: String { get { read(\.stemRoots, "stemRoots", "") } set { write(\.stemRoots, "stemRoots", newValue, "") } } + var tabLayout: String { get { read(\.tabLayout, "tabLayout", Tab.encode(Tab.defaultLayout)) } set { write(\.tabLayout, "tabLayout", newValue, Tab.encode(Tab.defaultLayout)) } } var theme: ThemeMode { get { readEnum(\.theme, "theme", .Black) } set { writeEnum(\.theme, "theme", newValue, .Black) } } var accent: Accent { get { readEnum(\.accent, "accent", .Music) } set { writeEnum(\.accent, "accent", newValue, .Music) } } var albumColorNowPlaying: Bool { get { read(\.albumColorNowPlaying, "albumColorNowPlaying", false) } set { write(\.albumColorNowPlaying, "albumColorNowPlaying", newValue, false) } } diff --git a/ios/Stemify/Engine/StemDecoder.swift b/ios/Stemify/Engine/StemDecoder.swift index d782cfe..d7bf44b 100644 --- a/ios/Stemify/Engine/StemDecoder.swift +++ b/ios/Stemify/Engine/StemDecoder.swift @@ -369,7 +369,7 @@ final class StemDecoder: @unchecked Sendable { } } catch { if !released { - NSLog("StemDecoder %@ failed: %@", name, error.localizedDescription) + Log.shared.error("decoder", "\(name) failed: \(error.localizedDescription)") state.with { _error = error } } ring.eos = true diff --git a/ios/Stemify/Playback/PlaybackManager.swift b/ios/Stemify/Playback/PlaybackManager.swift index ca8e145..2302488 100644 --- a/ios/Stemify/Playback/PlaybackManager.swift +++ b/ios/Stemify/Playback/PlaybackManager.swift @@ -181,6 +181,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { /// Surfaces a message wherever messages show (toasts on the phone, an alert in the car). func report(_ text: String) { + Log.shared.error("report", text) message = AppMessage(text: text) messageListeners.values.forEach { $0(text) } } @@ -797,6 +798,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { guard let self, let info = n.userInfo, let raw = info[AVAudioSessionInterruptionTypeKey] as? UInt, let type = AVAudioSession.InterruptionType(rawValue: raw) else { return } + Log.shared.info("session", "interruption \(type == .began ? "began" : "ended")") switch type { case .began: // A stale "began" arrives after the app was suspended; the session wasn't really taken. @@ -813,12 +815,14 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { nc.addObserver(forName: AVAudioSession.routeChangeNotification, object: nil, queue: .main) { [weak self] n in guard let self, let raw = n.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt, let reason = AVAudioSession.RouteChangeReason(rawValue: raw) else { return } + Log.shared.info("route", "change reason=\(reason.rawValue) now \(AirRoute.describe())") // Headphones unplugged or Bluetooth gone: don't carry on out of the speaker. if reason == .oldDeviceUnavailable && self.playWhenReady { self.pause() } self.eq.refreshRoute() } nc.addObserver(forName: AVAudioSession.mediaServicesWereResetNotification, object: nil, queue: .main) { [weak self] _ in guard let self else { return } + Log.shared.error("session", "media services reset") let was = self.playWhenReady let pos = self.positionMs() self.currentToken = -1 diff --git a/ios/Stemify/UI/Components/Chrome.swift b/ios/Stemify/UI/Components/Chrome.swift index 28fb4bf..cf0f08f 100644 --- a/ios/Stemify/UI/Components/Chrome.swift +++ b/ios/Stemify/UI/Components/Chrome.swift @@ -1,4 +1,6 @@ +import AVKit import SwiftUI +import UIKit let MARGIN: CGFloat = 20 @@ -330,3 +332,21 @@ private struct ListRow: ViewModifier { } } } + +/// The system AirPlay / Bluetooth output picker. The mix is rendered on the phone, so stem edits keep working over AirPlay. +struct AirPlayButton: UIViewRepresentable { + var tint: UIColor = .label + var active: UIColor = .systemBlue + + func makeUIView(context: Context) -> AVRoutePickerView { + let v = AVRoutePickerView() + v.prioritizesVideoDevices = false + v.backgroundColor = .clear + return v + } + + func updateUIView(_ v: AVRoutePickerView, context: Context) { + v.tintColor = tint + v.activeTintColor = active + } +} diff --git a/ios/Stemify/UI/Nav.swift b/ios/Stemify/UI/Nav.swift index 2f50660..2782289 100644 --- a/ios/Stemify/UI/Nav.swift +++ b/ios/Stemify/UI/Nav.swift @@ -1,11 +1,32 @@ import SwiftUI enum Tab: String, CaseIterable, Hashable { - case Library, Playlists, Search, Downloads, Settings + case Library, Songs, Albums, Artists, Genres, Playlists, Search, Downloads, Settings + + static let defaultLayout: [Tab] = [.Library, .Playlists, .Search, .Downloads, .Settings] + /// iOS folds anything past five tabs into "More", so the bar holds at most this many. + static let maxVisible = 5 + + /// Parses the saved comma list; Settings is always kept so the app can't lock itself out. + static func layout(_ raw: String) -> [Tab] { + var out: [Tab] = [] + for part in raw.split(separator: ",") { + if let t = Tab(rawValue: String(part)), !out.contains(t) { out.append(t) } + } + if out.isEmpty { return defaultLayout } + if !out.contains(.Settings) { out = Array(out.prefix(maxVisible - 1)) + [.Settings] } + return Array(out.prefix(maxVisible)) + } + + static func encode(_ tabs: [Tab]) -> String { tabs.map(\.rawValue).joined(separator: ",") } var icon: String { switch self { case .Library: "square.stack.fill" + case .Songs: "music.note" + case .Albums: "square.stack" + case .Artists: "music.mic" + case .Genres: "guitars.fill" case .Playlists: "music.note.list" case .Search: "magnifyingglass" case .Downloads: "arrow.down.circle.fill" @@ -15,10 +36,10 @@ enum Tab: String, CaseIterable, Hashable { } enum Route: Hashable { - case Artists, Albums, Songs, Playlists + case Artists, Albums, Songs, Playlists, Genres, Downloads, TabBar case Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String) case PlexAccount, StemServer, Quality, Storage, Appearance - case ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist), StemPlaylistPage(String) + case GenrePage(String), ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist), StemPlaylistPage(String) } /// Tabs, their stacks and the Now Playing sheet: the things any screen can ask of the shell. @@ -47,13 +68,30 @@ final class Ui { if t == tab { paths[t] = [] } else { tab = t } } + /// The tabs currently in the bar, in order. + var tabs: [Tab] { Tab.layout(AppGraph.shared.settings.tabLayout) } + + /// Where library pages open: Library if it's in the bar, else the first tab that is. + private var home: Tab { tabs.contains(.Library) ? .Library : (tabs.first ?? .Settings) } + + /// Downloads as its own tab, or pushed onto the current one when it's hidden. + func openDownloads() { + if tabs.contains(.Downloads) { selectTab(.Downloads) } else { push(.Downloads) } + } + + /// Falls back to the first tab when the selected one was just removed from the bar. + func ensureValidTab() { + if !tabs.contains(tab) { tab = tabs.first ?? .Settings } + } + func openNowPlaying() { nowPlayingOpen = true } /// Switches to the Library tab and pushes [r] there. func showInLibrary(_ r: Route) { nowPlayingOpen = false - tab = .Library - paths[.Library, default: []].append(r) + let t = home + tab = t + paths[t, default: []].append(r) } /// Switches to Settings and opens [r] (the Equalizer) there. diff --git a/ios/Stemify/UI/RootView.swift b/ios/Stemify/UI/RootView.swift index 9637a37..24d32a9 100644 --- a/ios/Stemify/UI/RootView.swift +++ b/ios/Stemify/UI/RootView.swift @@ -9,7 +9,7 @@ struct RootView: View { let s = g.settings let p = Palette.of(s.theme, s.accent) TabView(selection: Binding(get: { ui.tab }, set: { ui.selectTab($0) })) { - ForEach(Tab.allCases, id: \.self) { t in + ForEach(Tab.layout(s.tabLayout), id: \.self) { t in NavigationStack(path: ui.path(t)) { root(t).miniPlayerRoom() .navigationDestination(for: Route.self) { destination($0).miniPlayerRoom() } @@ -20,6 +20,7 @@ struct RootView: View { } } .modifier(GlassMiniPlayerHost()) + .onChange(of: s.tabLayout) { _, _ in ui.ensureValidTab() } .sheet(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) { NowPlayingScreen() .overlayHost(ui.overlays, level: 1) @@ -41,6 +42,10 @@ struct RootView: View { @ViewBuilder private func root(_ t: Tab) -> some View { switch t { case .Library: LibraryHome() + case .Songs: SongsScreen() + case .Albums: AlbumsScreen() + case .Artists: ArtistsScreen() + case .Genres: GenresScreen() case .Playlists: PlaylistsScreen() case .Search: SearchScreen() case .Downloads: DownloadsScreen() @@ -54,6 +59,10 @@ struct RootView: View { case .Albums: AlbumsScreen() case .Songs: SongsScreen() case .Playlists: PlaylistsScreen() + case .Genres: GenresScreen() + case .Downloads: DownloadsScreen() + case .TabBar: TabBarScreen() + case .GenrePage(let g): GenreScreen(genre: g) case .Eq: EqScreen() case .EqFilters(let id): EqFiltersScreen(profileId: id) case .EqFilterPage(let id, let f): EqFilterPage(profileId: id, filterId: f) diff --git a/ios/Stemify/UI/Screens/LibraryScreens.swift b/ios/Stemify/UI/Screens/LibraryScreens.swift index d0a86df..9f8630f 100644 --- a/ios/Stemify/UI/Screens/LibraryScreens.swift +++ b/ios/Stemify/UI/Screens/LibraryScreens.swift @@ -642,3 +642,56 @@ struct DownloadsScreen: View { } } } + +// ---- genres ----------------------------------------------------------------------------------- + +struct GenresScreen: View { + @Environment(Ui.self) private var ui + @Environment(\.palette) private var p + @State private var albums: LoadState<[Album]> = .loading + @State private var reload = 0 + + var body: some View { + Page(title: "Genres") { + Loaded(state: albums, retry: { reload += 1 }) { list in + let counts = Dictionary(grouping: list.compactMap(\.genre).filter { !$0.isEmpty }, by: { $0 }).mapValues(\.count) + LazyVStack(spacing: 0) { + ForEach(counts.keys.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }, id: \.self) { g in + Button { ui.push(.GenrePage(g)) } label: { + HStack { + Text(g).foregroundStyle(p.label) + Spacer() + Text("\(counts[g] ?? 0)").foregroundStyle(p.label2) + Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(p.label2) + } + .padding(.horizontal, 16).padding(.vertical, 12) + } + .buttonStyle(.plain) + Divider().padding(.leading, 16) + } + } + } + } + .task(id: "\(AppGraph.shared.settings.musicSection)|\(reload)") { + await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: "titleSort") } + } + } +} + +struct GenreScreen: View { + @Environment(Ui.self) private var ui + let genre: String + @State private var albums: LoadState<[Album]> = .loading + @State private var reload = 0 + + var body: some View { + Page(title: genre) { + Loaded(state: albums, retry: { reload += 1 }) { list in + AlbumGrid(albums: list.filter { $0.genre == genre }, menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) }).padding(.top, 8) + } + } + .task(id: "\(AppGraph.shared.settings.musicSection)|\(reload)") { + await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: "titleSort") } + } + } +} diff --git a/ios/Stemify/UI/Screens/NowPlayingScreen.swift b/ios/Stemify/UI/Screens/NowPlayingScreen.swift index c7cd205..035237c 100644 --- a/ios/Stemify/UI/Screens/NowPlayingScreen.swift +++ b/ios/Stemify/UI/Screens/NowPlayingScreen.swift @@ -172,7 +172,7 @@ struct NowPlayingScreen: View { .transition(.opacity.combined(with: .move(edge: .top))) } ProgressVolumeBar(played: c.label, unplayed: c.label3, centerLabel: badge(track)) - .padding(.top, wide ? 24 : panel == .Art ? 34 : 18) + .padding(.top, wide ? 24 : panel == .Art ? 34 : 30) HStack { SideToggle(symbol: "shuffle", on: pm.shuffle, action: pm.toggleShuffle) Spacer() @@ -199,6 +199,7 @@ struct NowPlayingScreen: View { PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) } SpeedMenu() Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity) + AirPlayButton(tint: UIColor(c.label2), active: UIColor(c.accent)).frame(width: 40, height: 40) PanelToggle(symbol: "music.mic", on: pm.karaoke) { karaoke() } PanelToggle(symbol: "list.bullet", on: panel == .Queue) { show(.Queue) } } diff --git a/ios/Stemify/UI/Screens/SettingsScreens.swift b/ios/Stemify/UI/Screens/SettingsScreens.swift index 38e24f9..60a9113 100644 --- a/ios/Stemify/UI/Screens/SettingsScreens.swift +++ b/ios/Stemify/UI/Screens/SettingsScreens.swift @@ -153,6 +153,11 @@ struct SettingsScreen: View { } header: { Header(text: "Backup") } footer: { Footer(text: "Stem playlists, their mixes and sound settings. Tokens and server addresses are left out. Save to iCloud Drive from the Files sheet.") } + Section { + DebugCells() + } header: { Header(text: "Debug") } footer: { + Footer(text: "Verbose adds playback and network detail. Tokens are removed from the log.") + } Section { NavCell(title: "Appearance", value: s.theme.label, icon: "paintbrush.fill", iconBg: 0x007AFF, route: .Appearance) } header: { Header(text: "Display") } footer: { @@ -508,7 +513,7 @@ struct StorageScreen: View { let usage = g.cache.usage List { Section { - Button { ui.selectTab(.Downloads) } label: { + Button { ui.openDownloads() } label: { HStack { ValueCell(title: "Downloads", value: gb(usage.pinnedBytes)) Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(p.label3) @@ -598,6 +603,9 @@ struct AppearanceScreen: View { .tint(Color(hex: 0x34C759)) .themedRow() } header: { Header(text: "Now Playing") } footer: { Footer(text: "Paints the Now Playing screen in one solid colour taken from the album art.") } + Section { + NavigationLink(value: Route.TabBar) { Text("Tab Bar").foregroundStyle(c.label) }.themedRow() + } } .listStyle(.insetGrouped) .themedList() @@ -605,3 +613,111 @@ struct AppearanceScreen: View { .navigationBarTitleDisplayMode(.inline) } } + +// ---- tab bar editor --------------------------------------------------------------------------- + +struct TabBarScreen: View { + @Environment(\.palette) private var c + private var g: AppGraph { .shared } + + var body: some View { + @Bindable var s = g.settings + let shown = Tab.layout(s.tabLayout) + let hidden = Tab.allCases.filter { !shown.contains($0) } + List { + Section { + ForEach(shown, id: \.self) { t in + HStack { + Label(t.rawValue, systemImage: t.icon).foregroundStyle(c.label) + if t == .Settings { Spacer(); Text("Always on").font(.footnote).foregroundStyle(c.label2) } + } + .themedRow() + .deleteDisabled(t == .Settings) + } + .onMove { from, to in + var l = shown + l.move(fromOffsets: from, toOffset: to) + s.tabLayout = Tab.encode(l) + } + .onDelete { idx in + s.tabLayout = Tab.encode(shown.enumerated().filter { !idx.contains($0.offset) }.map(\.element)) + } + } header: { Header(text: "In the bar") } footer: { Footer(text: "Drag to reorder, swipe to remove. The bar holds \(Tab.maxVisible) tabs.") } + if !hidden.isEmpty { + Section { + ForEach(hidden, id: \.self) { t in + Button { s.tabLayout = Tab.encode(shown + [t]) } label: { + HStack { + Label(t.rawValue, systemImage: t.icon).foregroundStyle(c.label) + Spacer() + Image(systemName: "plus.circle.fill").foregroundStyle(shown.count < Tab.maxVisible ? c.accent : c.label2) + } + } + .disabled(shown.count >= Tab.maxVisible) + .themedRow() + } + } header: { Header(text: "Available") } + } + Section { + Button("Reset to default") { s.tabLayout = Tab.encode(Tab.defaultLayout) }.themedRow() + } + } + .environment(\.editMode, .constant(.active)) + .listStyle(.insetGrouped) + .themedList() + .navigationTitle("Tab Bar") + .navigationBarTitleDisplayMode(.inline) + } +} + + +// ---- debug ---------------------------------------------------------------------------------- + +private struct DebugCells: View { + @AppStorage(Log.verboseKey) private var verbose = false + @State private var logURL: URL? + @State private var viewing = false + @Environment(\.palette) private var p + + var body: some View { + Toggle(isOn: $verbose) { + HStack(spacing: 14) { + SettingsIcon(symbol: "ladybug.fill", bg: 0xFF9500) + Text("Verbose Logging").foregroundStyle(p.label) + } + } + .tint(Color(hex: 0x34C759)) + .themedRow() + Button { viewing = true } label: { Text("View Log").foregroundStyle(p.label) }.themedRow() + if let logURL { + ShareLink(item: logURL) { Text("Export Log").foregroundStyle(p.accent) }.themedRow() + } else { + Button { logURL = Log.shared.export() } label: { Text("Prepare Log for Export").foregroundStyle(p.accent) }.themedRow() + } + Button(role: .destructive) { Log.shared.clear(); logURL = nil } label: { Text("Clear Log") }.themedRow() + .sheet(isPresented: $viewing) { LogViewer() } + } +} + +private struct LogViewer: View { + @State private var text = Log.shared.text() + @Environment(\.dismiss) private var dismiss + var body: some View { + NavigationStack { + ScrollView { + Text(text.isEmpty ? "Nothing logged yet." : text) + .font(.system(size: 11, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .textSelection(.enabled) + } + .defaultScrollAnchor(.bottom) + .navigationTitle("Log") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() } } + ToolbarItem(placement: .primaryAction) { Button("Refresh") { text = Log.shared.text() } } + } + } + } +}