Skip to content

Commit

Permalink
Merge pull request #731 from willowtreeapps/feature/voice-settings-pe…
Browse files Browse the repository at this point in the history
…rsonal-voice

[728] Add support for iOS 17 Personal Voice
  • Loading branch information
Clstroud authored May 6, 2024
2 parents f752453 + 54092d3 commit eb5bc39
Show file tree
Hide file tree
Showing 8 changed files with 338 additions and 12 deletions.
8 changes: 8 additions & 0 deletions Vocable.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,8 @@
B2BC6CB12BDAA83400D459F6 /* VoiceProfilePreviewDataSource+Filter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BC6CB02BDAA83400D459F6 /* VoiceProfilePreviewDataSource+Filter.swift */; };
B2BC6CB32BDAB2A600D459F6 /* VoiceSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BC6CB22BDAB2A600D459F6 /* VoiceSettingsViewController.swift */; };
B2BC6CB52BDAD1B500D459F6 /* VoiceProfileItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BC6CB42BDAD1B500D459F6 /* VoiceProfileItem.swift */; };
B2BC6CB72BDBFCC100D459F6 /* PersonalVoiceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BC6CB62BDBFCC100D459F6 /* PersonalVoiceViewController.swift */; };
B2BC6CB92BDC241200D459F6 /* PersonalVoicePermissionPromptController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BC6CB82BDC241200D459F6 /* PersonalVoicePermissionPromptController.swift */; };
B838200F23F4B011005A79CD /* VocableCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B838200E23F4B011005A79CD /* VocableCollectionViewCell.swift */; };
B879831523E0DA8300DC1A81 /* PresetItemCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B879831323E0DA8300DC1A81 /* PresetItemCollectionViewCell.swift */; };
B8DA9DE823EB833200FEBE19 /* BorderedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8DA9DE723EB833200FEBE19 /* BorderedView.swift */; };
Expand Down Expand Up @@ -593,6 +595,8 @@
B2BC6CB02BDAA83400D459F6 /* VoiceProfilePreviewDataSource+Filter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VoiceProfilePreviewDataSource+Filter.swift"; sourceTree = "<group>"; };
B2BC6CB22BDAB2A600D459F6 /* VoiceSettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VoiceSettingsViewController.swift; sourceTree = "<group>"; };
B2BC6CB42BDAD1B500D459F6 /* VoiceProfileItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceProfileItem.swift; sourceTree = "<group>"; };
B2BC6CB62BDBFCC100D459F6 /* PersonalVoiceViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalVoiceViewController.swift; sourceTree = "<group>"; };
B2BC6CB82BDC241200D459F6 /* PersonalVoicePermissionPromptController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalVoicePermissionPromptController.swift; sourceTree = "<group>"; };
B838200E23F4B011005A79CD /* VocableCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocableCollectionViewCell.swift; sourceTree = "<group>"; };
B879831323E0DA8300DC1A81 /* PresetItemCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetItemCollectionViewCell.swift; sourceTree = "<group>"; };
B8D55689242FE8B900B0F6FE /* Phrases v2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Phrases v2.xcdatamodel"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -839,6 +843,8 @@
children = (
B2BC6CB22BDAB2A600D459F6 /* VoiceSettingsViewController.swift */,
6432696B2BD16F46005F5AF7 /* VoicePickerViewController.swift */,
B2BC6CB62BDBFCC100D459F6 /* PersonalVoiceViewController.swift */,
B2BC6CB82BDC241200D459F6 /* PersonalVoicePermissionPromptController.swift */,
B2BC6CA82BD94F5A00D459F6 /* VocableListContentConfiguration+VoiceProfileItem.swift */,
B2BC6CB42BDAD1B500D459F6 /* VoiceProfileItem.swift */,
B2BC6CAA2BD94FB500D459F6 /* VoiceProfilePreviewSynthesizer.swift */,
Expand Down Expand Up @@ -1547,6 +1553,7 @@
6BB56BA82422AC2500EA787E /* ToastView.swift in Sources */,
A9DFCC20242D020100136136 /* PublishedValue.swift in Sources */,
6B5C73A727DFE61600004713 /* PredicateBuilders.swift in Sources */,
B2BC6CB92BDC241200D459F6 /* PersonalVoicePermissionPromptController.swift in Sources */,
A9C32CFF242271F3000EC6F7 /* Phrase+Helpers.swift in Sources */,
64981D7C258A8014007EFA82 /* VocableChoicesModel.mlmodel in Sources */,
64F49906245B40BC00348592 /* SettingsViewController.swift in Sources */,
Expand Down Expand Up @@ -1583,6 +1590,7 @@
A916F33E28490B2E00557CA2 /* UIApplication+Helpers.swift in Sources */,
A9DE16FE23F48FD30094DB64 /* TextSuggestionController.swift in Sources */,
A91030B227E3BF6600281C97 /* VocableListCellContentView.swift in Sources */,
B2BC6CB72BDBFCC100D459F6 /* PersonalVoiceViewController.swift in Sources */,
220DDD612A53CAC30058C7A9 /* ListenAPIClient.swift in Sources */,
6BE7E9882459DFD5007B01F2 /* VocableNavigationBar.swift in Sources */,
6B5C490D245CB92700A4433C /* CategoryDetailViewController.swift in Sources */,
Expand Down
8 changes: 8 additions & 0 deletions Vocable/Common/Views/EmptyStateView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ protocol EmptyStateRepresentable {
var yOffset: CGFloat? { get }
}

// Default implementations of optional properties
extension EmptyStateRepresentable {
var description: String? { nil }
var buttonTitle: String? { nil }
var image: UIImage? { nil }
var yOffset: CGFloat? { nil }
}

enum EmptyStateType: EmptyStateRepresentable {

case recents
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
//
// PersonalVoicePermissionPromptController.swift
// Vocable
//
// Created by Chris Stroud on 4/26/24.
// Copyright © 2024 WillowTree. All rights reserved.
//

import Foundation
import AVFoundation
import Combine
import UIKit

@available(iOS 17.0, *)
final class PersonalVoicePermissionPromptController {

private typealias AuthorizationStatus = AVSpeechSynthesizer.PersonalVoiceAuthorizationStatus

struct PersonalVoicePermissionEmptyState {
let state: PersonalVoiceEmptyState
let action: EmptyStateView.ButtonConfiguration
}

@Published private(set) var state: PersonalVoicePermissionEmptyState? = .none

private var cancellables = Set<AnyCancellable>()

private var authorizationStatus: AuthorizationStatus {
AVSpeechSynthesizer.personalVoiceAuthorizationStatus
}

init() {
self.authorizationStatusDidChange(authorizationStatus)
NotificationCenter.default
.publisher(
for: AVSpeechSynthesizer.availableVoicesDidChangeNotification,
object: nil
)
.compactMap { [weak self] _ in
self?.authorizationStatus
}
.receive(on: DispatchQueue.main)
.sink { [weak self] (status) in
self?.authorizationStatusDidChange(status)
}
.store(in: &cancellables)
}

private func authorizationStatusDidChange(_ status: AuthorizationStatus) {
switch status {
case .authorized:
self.state = nil
case .denied: // Need to go to settings
self.state = .init(state: .denied) {
UIApplication.openSettingsURL()
}
case .notDetermined: // Need to present alert
self.state = .init(state: .notAuthorized) { [weak self] in
AVSpeechSynthesizer.requestPersonalVoiceAuthorization { status in
self?.authorizationStatusDidChange(status)
}
}
default:
self.state = nil
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
//
// PersonalVoiceViewController.swift
// Vocable
//
// Created by Chris Stroud on 4/26/24.
// Copyright © 2024 WillowTree. All rights reserved.
//

import Foundation
import UIKit
import AVFoundation
import Combine

@available(iOS 17.0, *)
final class PersonalVoiceViewController: PagingCarouselViewController {

private typealias DataSource = CarouselCollectionViewDataSourceProxy<Int, VoiceProfileItem>
private typealias CellRegistration = UICollectionView.CellRegistration<VocableListCell, VoiceProfileItem>

private var dataSource: DataSource!
private var cellRegistration: CellRegistration!
private let authorizationController = PersonalVoicePermissionPromptController()

private let previewController = VoiceProfilePreviewController(context: .personalVoice)
private var cancellables: Set<AnyCancellable> = []

override func viewDidLoad() {
super.viewDidLoad()

updateLayoutForCurrentTraitCollection()

setupNavigationBar()
setupCollectionView()
updateDataSource()

Publishers.CombineLatest(previewController.$items, authorizationController.$state)
.receive(on: DispatchQueue.main)
.sink { [weak self] (items, state) in
self?.updateDataSource(items: items, authorizationState: state)
}
.store(in: &cancellables)
}

override func viewLayoutMarginsDidChange() {
super.viewLayoutMarginsDidChange()
updateBackgroundViewLayoutMargins()
}

private func setupNavigationBar() {
navigationBar.title = String(localized: "personal_voices.title")
}

private func setupCollectionView() {
collectionView.allowsMultipleSelection = true
collectionView.allowsMultipleSelectionDuringEditing = true
collectionView.backgroundColor = .collectionViewBackgroundColor

let cellRegistration = CellRegistration { [weak self] cell, _, item in
guard let self else { return }
cell.contentConfiguration = VocableListContentConfiguration.voiceProfileItem(
item,
controller: self.previewController
) { [weak self] in
self?.handleVoiceSelection(item.voice)
}
}

let dataSource = DataSource(collectionView: collectionView) { (collectionView, indexPath, voice) -> UICollectionViewCell? in
collectionView.dequeueConfiguredReusableCell(
using: cellRegistration,
for: indexPath,
item: voice
)
}
self.dataSource = dataSource
self.cellRegistration = cellRegistration
}

private func updateDataSource(
items: [VoiceProfileItem]? = nil,
previewing previewVoice: AVSpeechSynthesisVoice? = nil,
authorizationState: PersonalVoicePermissionPromptController.PersonalVoicePermissionEmptyState? = nil
) {
let authorizationState = authorizationState ?? authorizationController.state
let items = items ?? previewController.items
var snapshot = NSDiffableDataSourceSnapshot<Int, VoiceProfileItem>()
snapshot.appendSections([0])
snapshot.appendItems(items)
dataSource.apply(snapshot, animatingDifferences: false)

if let authorizationState {
setBackgroundView(EmptyStateView(type: authorizationState.state, action: authorizationState.action))
} else if snapshot.itemIdentifiers.isEmpty {
setBackgroundView(EmptyStateView(type: PersonalVoiceEmptyState.noContent))
} else {
setBackgroundView(nil)
}
}

private func setBackgroundView(_ view: UIView?) {
collectionView.backgroundView = view
updateBackgroundViewLayoutMargins()
}

override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
updateLayoutForCurrentTraitCollection()
}

private func updateLayoutForCurrentTraitCollection() {
collectionView.layout.interItemSpacing = .init(interRowSpacing: 8, interColumnSpacing: 30)

switch sizeClass {
case .hRegular_vRegular:
collectionView.layout.numberOfColumns = .fixedCount(2)
collectionView.layout.numberOfRows = .flexible(minHeight: .absolute(100))
case .hCompact_vRegular:
collectionView.layout.numberOfColumns = .fixedCount(1)
collectionView.layout.numberOfRows = .flexible(minHeight: .absolute(64))
case .hCompact_vCompact, .hRegular_vCompact:
collectionView.layout.numberOfColumns = .fixedCount(2)
collectionView.layout.numberOfRows = .flexible(minHeight: .absolute(64))
default:
break
}
}

func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
return false
}

func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt indexPath: IndexPath) -> Bool {
return false
}

private func updateBackgroundViewLayoutMargins() {
guard let backgroundView = collectionView.backgroundView else { return }
backgroundView.directionalLayoutMargins.leading = view.directionalLayoutMargins.leading
backgroundView.directionalLayoutMargins.trailing = view.directionalLayoutMargins.trailing
}

fileprivate func handleVoiceSelection(_ voice: AVSpeechSynthesisVoice) {
AppConfig.selectedVoiceIdentifier = voice.identifier
updateDataSource()
}
}

enum PersonalVoiceEmptyState: EmptyStateRepresentable {

case denied
case notAuthorized
case noContent

var title: String {
return switch self {
case .denied: String(localized: "personal_voices.empty_state.denied.title")
case .notAuthorized: String(localized: "personal_voices.empty_state.not_authorized.title")
case .noContent: String(localized: "personal_voices.empty_state.no_content.title")
}
}

var description: String? {
switch self {
case .denied:
let format = String(localized: "personal_voices.empty_state.denied.description")
return String(format: format, UIDevice.current.model)
case .noContent:
let format = String(localized: "personal_voices.empty_state.no_content.description")
return String(format: format, UIDevice.current.model)
case .notAuthorized:
return String(localized: "personal_voices.empty_state.not_authorized.description")
}
}

var buttonTitle: String? {
return switch self {
case .denied: String(localized: "personal_voices.empty_state.denied.button.title")
case .notAuthorized: String(localized: "personal_voices.empty_state.not_authorized.button.title")
case .noContent: String(localized: "personal_voices.empty_state.no_content.button.title")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ final class VoiceProfilePreviewController {
enum PresentationContext {
case selectedProfilePreview
case voiceSelection
case personalVoice
}

let context: PresentationContext
Expand All @@ -34,6 +35,8 @@ final class VoiceProfilePreviewController {
self.dataSource = .init(filter: .selectedVoice)
case .voiceSelection:
self.dataSource = .init(filter: .systemVoices)
case .personalVoice:
self.dataSource = .init(filter: .personalVoices)
}

synthesizer.delegate = self
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,14 @@ extension VoiceProfilePreviewDataSource.Filter {
}
}
}

static var personalVoices: Self {
Self { (voice: AVSpeechSynthesisVoice, isSelected: Bool) in
if #available(iOS 17.0, *) {
return voice.voiceTraits.contains(.isPersonalVoice)
} else {
return false
}
}
}
}
Loading

0 comments on commit eb5bc39

Please sign in to comment.