From e4fd98c24409dc987809e343b364efb240fb8580 Mon Sep 17 00:00:00 2001 From: Joe Date: Tue, 6 Aug 2024 20:56:24 -0600 Subject: [PATCH] [tvOS] Settings Cleanup (#1163) * Settings Cleanup. Replace strings with labels. Enforce the same font. Ensure Forms don't get clipped by their boundries. Create consistent, reusable button sizing/coloring. Apply to all Settings Pages. * Remove custom Button/Form styling in exchange for just using .scrollClipDisabled() * Swap back to Jellyfin Purple from Purple. * Remove Check Button. Check all Section Inits where possible. Make Server Details Server non-focusable. Create a new menu for Server Details selection. This is a WIP awaiting feedback from https://github.com/jellyfin/Swiftfin/pull/1163#discussion_r1705957885 --------- Co-authored-by: Joseph Kribs --- Shared/Components/TextPairView.swift | 1 + Shared/Strings/Strings.swift | 36 +++++++++++++ .../Components/SplitFormWindowView.swift | 1 + Swiftfin tvOS/Views/SelectServerView.swift | 11 ++-- Swiftfin tvOS/Views/ServerDetailView.swift | 47 +++++++++++++---- .../SettingsView/CustomizeViewsSettings.swift | 24 +++------ .../ExperimentalSettingsView.swift | 11 ++-- .../SettingsView/IndicatorSettingsView.swift | 12 ++--- .../MaximumBitrateSettingsView.swift | 16 +++--- .../Views/SettingsView/SettingsView.swift | 29 ++++++----- .../VideoPlayerSettingsView.swift | 49 ++++++++++-------- Translations/en.lproj/Localizable.strings | Bin 23678 -> 27976 bytes 12 files changed, 147 insertions(+), 90 deletions(-) diff --git a/Shared/Components/TextPairView.swift b/Shared/Components/TextPairView.swift index 03d48459..66f26828 100644 --- a/Shared/Components/TextPairView.swift +++ b/Shared/Components/TextPairView.swift @@ -19,6 +19,7 @@ struct TextPairView: View { var body: some View { HStack { Text(leading) + .foregroundColor(.primary) Spacer() diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index 8004ccc6..1fa91a3f 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -18,6 +18,8 @@ internal enum L10n { internal static let accentColorDescription = L10n.tr("Localizable", "accentColorDescription", fallback: "Some views may need an app restart to update.") /// Accessibility internal static let accessibility = L10n.tr("Localizable", "accessibility", fallback: "Accessibility") + /// Add Server + internal static let addServer = L10n.tr("Localizable", "addServer", fallback: "Add Server") /// Add URL internal static let addURL = L10n.tr("Localizable", "addURL", fallback: "Add URL") /// Advanced @@ -30,6 +32,8 @@ internal enum L10n { internal static let allGenres = L10n.tr("Localizable", "allGenres", fallback: "All Genres") /// All Media internal static let allMedia = L10n.tr("Localizable", "allMedia", fallback: "All Media") + /// All Servers + internal static let allServers = L10n.tr("Localizable", "allServers", fallback: "All Servers") /// Appearance internal static let appearance = L10n.tr("Localizable", "appearance", fallback: "Appearance") /// App Icon @@ -112,6 +116,8 @@ internal enum L10n { internal static let chapterSlider = L10n.tr("Localizable", "chapterSlider", fallback: "Chapter Slider") /// Cinematic internal static let cinematic = L10n.tr("Localizable", "cinematic", fallback: "Cinematic") + /// Cinematic Background + internal static let cinematicBackground = L10n.tr("Localizable", "cinematicBackground", fallback: "Cinematic Background") /// Cinematic Views internal static let cinematicViews = L10n.tr("Localizable", "cinematicViews", fallback: "Cinematic Views") /// Close @@ -160,6 +166,10 @@ internal enum L10n { internal static let dark = L10n.tr("Localizable", "dark", fallback: "Dark") /// Default Scheme internal static let defaultScheme = L10n.tr("Localizable", "defaultScheme", fallback: "Default Scheme") + /// Delete + internal static let delete = L10n.tr("Localizable", "delete", fallback: "Delete") + /// Delete Server + internal static let deleteServer = L10n.tr("Localizable", "deleteServer", fallback: "Delete Server") /// Delivery internal static let delivery = L10n.tr("Localizable", "delivery", fallback: "Delivery") /// DIRECTOR @@ -176,6 +186,8 @@ internal enum L10n { internal static let downloads = L10n.tr("Localizable", "downloads", fallback: "Downloads") /// Edit Jump Lengths internal static let editJumpLengths = L10n.tr("Localizable", "editJumpLengths", fallback: "Edit Jump Lengths") + /// Edit Server + internal static let editServer = L10n.tr("Localizable", "editServer", fallback: "Edit Server") /// Empty Next Up internal static let emptyNextUp = L10n.tr("Localizable", "emptyNextUp", fallback: "Empty Next Up") /// Enabled @@ -228,6 +240,8 @@ internal enum L10n { internal static let invertedLight = L10n.tr("Localizable", "invertedLight", fallback: "Inverted Light") /// Items internal static let items = L10n.tr("Localizable", "items", fallback: "Items") + /// Jellyfin + internal static let jellyfin = L10n.tr("Localizable", "jellyfin", fallback: "Jellyfin") /// Jump internal static let jump = L10n.tr("Localizable", "jump", fallback: "Jump") /// Jump Backward @@ -338,6 +352,8 @@ internal enum L10n { } /// No title internal static let noTitle = L10n.tr("Localizable", "noTitle", fallback: "No title") + /// Offset + internal static let offset = L10n.tr("Localizable", "offset", fallback: "Offset") /// Ok internal static let ok = L10n.tr("Localizable", "ok", fallback: "Ok") /// 1 user @@ -380,6 +396,8 @@ internal enum L10n { internal static let playback = L10n.tr("Localizable", "playback", fallback: "Playback") /// Playback Buttons internal static let playbackButtons = L10n.tr("Localizable", "playbackButtons", fallback: "Playback Buttons") + /// Playback Quality + internal static let playbackQuality = L10n.tr("Localizable", "playbackQuality", fallback: "Playback Quality") /// Playback settings internal static let playbackSettings = L10n.tr("Localizable", "playbackSettings", fallback: "Playback settings") /// Playback Speed @@ -474,12 +492,16 @@ internal enum L10n { internal static let resetAppSettings = L10n.tr("Localizable", "resetAppSettings", fallback: "Reset App Settings") /// Reset User Settings internal static let resetUserSettings = L10n.tr("Localizable", "resetUserSettings", fallback: "Reset User Settings") + /// Resume + internal static let resume = L10n.tr("Localizable", "resume", fallback: "Resume") /// Resume 5 Second Offset internal static let resume5SecondOffset = L10n.tr("Localizable", "resume5SecondOffset", fallback: "Resume 5 Second Offset") /// Resume Offset internal static let resumeOffset = L10n.tr("Localizable", "resumeOffset", fallback: "Resume Offset") /// Resume content seconds before the recorded resume time internal static let resumeOffsetDescription = L10n.tr("Localizable", "resumeOffsetDescription", fallback: "Resume content seconds before the recorded resume time") + /// Resume Offset + internal static let resumeOffsetTitle = L10n.tr("Localizable", "resumeOffsetTitle", fallback: "Resume Offset") /// Retrieving media information internal static let retrievingMediaInformation = L10n.tr("Localizable", "retrievingMediaInformation", fallback: "Retrieving media information") /// Retry @@ -540,6 +562,10 @@ internal enum L10n { internal static let showCastAndCrew = L10n.tr("Localizable", "showCastAndCrew", fallback: "Show Cast & Crew") /// Show Chapters Info In Bottom Overlay internal static let showChaptersInfoInBottomOverlay = L10n.tr("Localizable", "showChaptersInfoInBottomOverlay", fallback: "Show Chapters Info In Bottom Overlay") + /// Show Favorited + internal static let showFavorited = L10n.tr("Localizable", "showFavorited", fallback: "Show Favorited") + /// Show Favorites + internal static let showFavorites = L10n.tr("Localizable", "showFavorites", fallback: "Show Favorites") /// Flatten Library Items internal static let showFlattenView = L10n.tr("Localizable", "showFlattenView", fallback: "Flatten Library Items") /// Show Missing Episodes @@ -548,6 +574,14 @@ internal enum L10n { internal static let showMissingSeasons = L10n.tr("Localizable", "showMissingSeasons", fallback: "Show Missing Seasons") /// Show Poster Labels internal static let showPosterLabels = L10n.tr("Localizable", "showPosterLabels", fallback: "Show Poster Labels") + /// Show Progress + internal static let showProgress = L10n.tr("Localizable", "showProgress", fallback: "Show Progress") + /// Show Recently Added + internal static let showRecentlyAdded = L10n.tr("Localizable", "showRecentlyAdded", fallback: "Show Recently Added") + /// Show Unwatched + internal static let showUnwatched = L10n.tr("Localizable", "showUnwatched", fallback: "Show Unwatched") + /// Show Watched + internal static let showWatched = L10n.tr("Localizable", "showWatched", fallback: "Show Watched") /// Signed in as %@ internal static func signedInAsWithString(_ p1: Any) -> String { return L10n.tr("Localizable", "signedInAsWithString", String(describing: p1), fallback: "Signed in as %@") @@ -594,6 +628,8 @@ internal enum L10n { internal static let subtitleOffset = L10n.tr("Localizable", "subtitleOffset", fallback: "Subtitle Offset") /// Subtitles internal static let subtitles = L10n.tr("Localizable", "subtitles", fallback: "Subtitles") + /// Settings only affect some subtitle types + internal static let subtitlesDisclaimer = L10n.tr("Localizable", "subtitlesDisclaimer", fallback: "Settings only affect some subtitle types") /// Subtitle Size internal static let subtitleSize = L10n.tr("Localizable", "subtitleSize", fallback: "Subtitle Size") /// Suggestions diff --git a/Swiftfin tvOS/Components/SplitFormWindowView.swift b/Swiftfin tvOS/Components/SplitFormWindowView.swift index bae4ef02..20fb319c 100644 --- a/Swiftfin tvOS/Components/SplitFormWindowView.swift +++ b/Swiftfin tvOS/Components/SplitFormWindowView.swift @@ -31,6 +31,7 @@ struct SplitFormWindowView: View { .if(descriptionTopPadding) { view in view.padding(.top) } + .scrollClipDisabled() } } } diff --git a/Swiftfin tvOS/Views/SelectServerView.swift b/Swiftfin tvOS/Views/SelectServerView.swift index 2f00e174..d2283656 100644 --- a/Swiftfin tvOS/Views/SelectServerView.swift +++ b/Swiftfin tvOS/Views/SelectServerView.swift @@ -39,7 +39,7 @@ struct SelectServerView: View { } var body: some View { - FullScreenMenu("Servers") { + FullScreenMenu(L10n.servers) { Section { Button { router.popLast { @@ -47,7 +47,7 @@ struct SelectServerView: View { } } label: { HStack { - Text("Add Server") + L10n.addServer.text Spacer() @@ -62,7 +62,7 @@ struct SelectServerView: View { } } label: { HStack { - Text("Edit Server") + L10n.editServer.text Spacer() @@ -80,7 +80,7 @@ struct SelectServerView: View { router.popLast() } label: { HStack { - Text("All Servers") + L10n.allServers.text Spacer() @@ -116,9 +116,10 @@ struct SelectServerView: View { .padding() } .buttonStyle(.card) + .padding(.horizontal) } } header: { - Text("Servers") + Text(L10n.servers) } .headerProminence(.increased) } diff --git a/Swiftfin tvOS/Views/ServerDetailView.swift b/Swiftfin tvOS/Views/ServerDetailView.swift index b6eabff0..84c8ac0c 100644 --- a/Swiftfin tvOS/Views/ServerDetailView.swift +++ b/Swiftfin tvOS/Views/ServerDetailView.swift @@ -35,36 +35,61 @@ struct EditServerView: View { .frame(maxWidth: 400) } .contentView { + Section(L10n.server) { TextPairView( leading: L10n.name, trailing: viewModel.server.name ) + .focusable(false) } - Section("URL") { - ForEach(viewModel.server.urls.sorted(using: \.absoluteString)) { url in - if url == viewModel.server.currentURL { - Button(url.absoluteString, systemImage: "checkmark") {} - } else { - Button(url.absoluteString) { + Section(L10n.url) { + Menu { + ForEach(viewModel.server.urls.sorted(using: \.absoluteString), id: \.self) { url in + Button(action: { + guard viewModel.server.currentURL != url else { return } viewModel.setCurrentURL(to: url) + }) { + HStack { + Text(url.absoluteString) + .foregroundColor(.primary) + + Spacer() + + if viewModel.server.currentURL == url { + Image(systemName: "checkmark") + .font(.body.weight(.regular)) + .foregroundColor(.secondary) + } + } } } + } label: { + HStack { + Text(viewModel.server.currentURL.absoluteString) + .foregroundColor(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + Spacer() + Image(systemName: "chevron.down") + .foregroundColor(.secondary) + } } } if isEditing { - ListRowButton("Delete") { - isPresentingConfirmDeletion = true + Section { + ListRowButton(L10n.delete) { + isPresentingConfirmDeletion = true + } + .foregroundStyle(.primary, .red.opacity(0.5)) } - .foregroundStyle(.primary, .red.opacity(0.5)) } } .withDescriptionTopPadding() .navigationTitle(L10n.server) - .alert("Delete Server", isPresented: $isPresentingConfirmDeletion) { - Button("Delete", role: .destructive) { + .alert(L10n.deleteServer, isPresented: $isPresentingConfirmDeletion) { + Button(L10n.delete, role: .destructive) { viewModel.delete() router.popLast() } diff --git a/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings.swift b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings.swift index 21d24185..ed54c55b 100644 --- a/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings.swift +++ b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings.swift @@ -53,18 +53,16 @@ struct CustomizeViewsSettings: View { } .contentView { - Section { + Section(L10n.missingItems) { Toggle(L10n.showMissingSeasons, isOn: $shouldShowMissingSeasons) Toggle(L10n.showMissingEpisodes, isOn: $shouldShowMissingEpisodes) - } header: { - L10n.missingItems.text } - Section { + Section(L10n.posters) { - ChevronButton("Indicators") + ChevronButton(L10n.indicators) .onSelect { router.route(to: \.indicatorSettings) } @@ -82,23 +80,17 @@ struct CustomizeViewsSettings: View { InlineEnumToggle(title: L10n.search, selection: $searchPosterType) InlineEnumToggle(title: L10n.library, selection: $libraryViewType) - - } header: { - Text("Posters") } - Section { + Section(L10n.library) { - Toggle("Cinematic Background", isOn: $cinematicBackground) + Toggle(L10n.cinematicBackground, isOn: $cinematicBackground) - Toggle("Random Image", isOn: $libraryRandomImage) + Toggle(L10n.randomImage, isOn: $libraryRandomImage) - Toggle("Show Favorites", isOn: $showFavorites) + Toggle(L10n.showFavorites, isOn: $showFavorites) - Toggle("Show Recently Added", isOn: $showRecentlyAdded) - - } header: { - L10n.library.text + Toggle(L10n.showRecentlyAdded, isOn: $showRecentlyAdded) } } .withDescriptionTopPadding() diff --git a/Swiftfin tvOS/Views/SettingsView/ExperimentalSettingsView.swift b/Swiftfin tvOS/Views/SettingsView/ExperimentalSettingsView.swift index 8aebc32e..65a50896 100644 --- a/Swiftfin tvOS/Views/SettingsView/ExperimentalSettingsView.swift +++ b/Swiftfin tvOS/Views/SettingsView/ExperimentalSettingsView.swift @@ -25,20 +25,15 @@ struct ExperimentalSettingsView: View { .frame(maxWidth: 400) } .contentView { - Section { + + Section("Video Player") { Toggle("Force Direct Play", isOn: $forceDirectPlay) - - } header: { - Text("Video Player") } - Section { + Section("Live TV") { Toggle("Live TV Force Direct Play", isOn: $liveTVForceDirectPlay) - - } header: { - Text("Live TV") } } .navigationTitle(L10n.experimental) diff --git a/Swiftfin tvOS/Views/SettingsView/IndicatorSettingsView.swift b/Swiftfin tvOS/Views/SettingsView/IndicatorSettingsView.swift index 955f026c..742c28f8 100644 --- a/Swiftfin tvOS/Views/SettingsView/IndicatorSettingsView.swift +++ b/Swiftfin tvOS/Views/SettingsView/IndicatorSettingsView.swift @@ -32,18 +32,18 @@ struct IndicatorSettingsView: View { } .contentView { - Section { + Section(L10n.posters) { - Toggle("Show Favorited", isOn: $showFavorited) + Toggle(L10n.showFavorited, isOn: $showFavorited) - Toggle("Show Progress", isOn: $showProgress) + Toggle(L10n.showProgress, isOn: $showProgress) - Toggle("Show Unwatched", isOn: $showUnwatched) + Toggle(L10n.showUnwatched, isOn: $showUnwatched) - Toggle("Show Watched", isOn: $showWatched) + Toggle(L10n.showWatched, isOn: $showWatched) } } .withDescriptionTopPadding() - .navigationTitle("Indicators") + .navigationTitle(L10n.indicators) } } diff --git a/Swiftfin tvOS/Views/SettingsView/MaximumBitrateSettingsView.swift b/Swiftfin tvOS/Views/SettingsView/MaximumBitrateSettingsView.swift index cda5ef1f..1f525488 100644 --- a/Swiftfin tvOS/Views/SettingsView/MaximumBitrateSettingsView.swift +++ b/Swiftfin tvOS/Views/SettingsView/MaximumBitrateSettingsView.swift @@ -24,21 +24,19 @@ struct MaximumBitrateSettingsView: View { .frame(maxWidth: 400) } .contentView { + Section { - CaseIterablePicker( - L10n.maximumBitrate, - selection: $appMaximumBitrate - ) + + InlineEnumToggle(title: L10n.maximumBitrate, selection: $appMaximumBitrate) if appMaximumBitrate == PlaybackBitrate.auto { - CaseIterablePicker( - L10n.testSize, - selection: $appMaximumBitrateTest - ) + InlineEnumToggle(title: L10n.testSize, selection: $appMaximumBitrateTest) } + } header: { + L10n.playbackQuality.text } footer: { if appMaximumBitrate == PlaybackBitrate.auto { - Text(L10n.bitrateTestDescription) + L10n.bitrateTestDescription.text } } } diff --git a/Swiftfin tvOS/Views/SettingsView/SettingsView.swift b/Swiftfin tvOS/Views/SettingsView/SettingsView.swift index 3a7d9e16..582e0bc2 100644 --- a/Swiftfin tvOS/Views/SettingsView/SettingsView.swift +++ b/Swiftfin tvOS/Views/SettingsView/SettingsView.swift @@ -31,7 +31,7 @@ struct SettingsView: View { .frame(maxWidth: 400) } .contentView { - Section { + Section(L10n.jellyfin) { Button {} label: { TextPairView( @@ -51,14 +51,23 @@ struct SettingsView: View { Button { viewModel.signOut() } label: { - L10n.switchUser.text - .foregroundColor(.jellyfinPurple) + HStack { + + Text(L10n.switchUser) + .foregroundColor(.jellyfinPurple) + + Spacer() + + Image(systemName: "chevron.right") + .font(.body.weight(.regular)) + .foregroundColor(.secondary) + } } } - Section { + Section(L10n.videoPlayer) { - InlineEnumToggle(title: "Video Player Type", selection: $videoPlayerType) + InlineEnumToggle(title: L10n.videoPlayerType, selection: $videoPlayerType) ChevronButton(L10n.videoPlayer) .onSelect { @@ -69,12 +78,9 @@ struct SettingsView: View { .onSelect { router.route(to: \.maximumBitrateSettings) } - - } header: { - L10n.videoPlayer.text } - Section { + Section(L10n.accessibility) { ChevronButton(L10n.customize) .onSelect { @@ -85,14 +91,11 @@ struct SettingsView: View { .onSelect { router.route(to: \.experimentalSettings) } - - } header: { - L10n.accessibility.text } Section { - ChevronButton("Logs") + ChevronButton(L10n.logs) .onSelect { router.route(to: \.log) } diff --git a/Swiftfin tvOS/Views/SettingsView/VideoPlayerSettingsView.swift b/Swiftfin tvOS/Views/SettingsView/VideoPlayerSettingsView.swift index c4039b4e..26440977 100644 --- a/Swiftfin tvOS/Views/SettingsView/VideoPlayerSettingsView.swift +++ b/Swiftfin tvOS/Views/SettingsView/VideoPlayerSettingsView.swift @@ -43,46 +43,51 @@ struct VideoPlayerSettingsView: View { .contentView { Section { + ChevronButton( - "Resume Offset", + L10n.offset, subtitle: resumeOffset.secondLabel ) .onSelect { isPresentingResumeOffsetStepper = true } + } header: { + L10n.resume.text } footer: { - Text("Resume content seconds before the recorded resume time") + L10n.resumeOffsetDescription.text } Section { + ChevronButton(L10n.subtitleFont, subtitle: subtitleFontName) .onSelect { router.route(to: \.fontPicker, $subtitleFontName) } + } header: { + L10n.subtitles.text } footer: { - Text("Settings only affect some subtitle types") + L10n.subtitlesDisclaimer.text } - Section { - - Toggle("Pause on background", isOn: $pauseOnBackground) - Toggle("Play on active", isOn: $playOnActive) + Section(L10n.playback) { + Toggle(L10n.pauseOnBackground, isOn: $pauseOnBackground) + Toggle(L10n.playOnActive, isOn: $playOnActive) } - } - .navigationTitle("Video Player") - .blurFullScreenCover(isPresented: $isPresentingResumeOffsetStepper) { - StepperView( - title: "Resume Offset", - description: "Resume content seconds before the recorded resume time", - value: $resumeOffset, - range: 0 ... 30, - step: 1 - ) - .valueFormatter { - $0.secondLabel - } - .onCloseSelected { - isPresentingResumeOffsetStepper = false + .navigationTitle(L10n.videoPlayer.text) + .blurFullScreenCover(isPresented: $isPresentingResumeOffsetStepper) { + StepperView( + title: L10n.resumeOffsetTitle, + description: L10n.resumeOffsetDescription, + value: $resumeOffset, + range: 0 ... 30, + step: 1 + ) + .valueFormatter { + $0.secondLabel + } + .onCloseSelected { + isPresentingResumeOffsetStepper = false + } } } } diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index cb199bd63925d3675d6aa8f142d745fe0ab6e923..077a43620c6fca2f02238662d28ca507797812df 100644 GIT binary patch delta 2775 zcmbtWO-~b16upgBXn~MYn$na)Tck)618RsNgpkrw)IumAxIhzYDToECodUrPiGKji z-I|c-P8UMhxY4*&6HVM2H*VY+@DCX8eKXUU=>VlQ&7?E$5o>=_gy&?5OA|p2h78ZmU>_4yP+sxeX(u2(eo(u_);nrO?;n zd-l8*56?A6DmOMlE-wk`Qm_hsv^d)xnp_!qmMPUJE16V{Vh6W<-Wk2jEKy7eNP+$} zqU@MPUsj;+=yP;{RCqYDCnM zhgn@Gby%r1q{OZ~?H26tQG75_EozeNvlq-n#rqk%l&KakN*=Bf+KTX!z4gN|y{91y zi)155teJ=G`A)AJ-zVDeb8oX{7!MMjVo_D74*nkTSY^ylBhjk8pN9M7A(v~Md|s#b zyvWUrs*PO2@+v%Nb>WLp6CSp<6!X49zo~gB7O~swb86WtDdSUE!8tW5 zN9TeuUy8^kE{ghkE)d7YxR>p0l`@~FD$&)&F?o2L?WBk~N1ip*w+)qH$qL*Fx$$ni zIa?jq8Op8=k$Y)Ukh_OjQ_~u<>Dn@l{ul3CWqdHsQ$NC&A&0kgRHr-ICfXZI`Vl(p zPUj?5REioj_s~tHuJ&xM2E#ah>u_SS+dg0zEn&#rr$9fh;6)KDLsT&lP;0C!5FJwE zQ)U+&RS{w6!L=*>eW%43qe_%L;+@;0UG4JGJ4zmNx5*0yv`9a|x-%Z%4VvMjYZDd( zCqo`Hn|}EiJ%KY!(B1{of|b$mbNJ`H%cGyG278K7C5qk({cG5He0SM{$xv;B33H&l zzcFGeQMX+tMV16IUqe{6K$mMvqVvu?ubqbGSmsDyR?ts)tb}^aktiav-^}2i$eIGJ zWG0-44+S>^1DM6}fP1_=ha}nJvyrjb{z)gu>%^VnU4SAQJq