diff --git a/4champ.xcodeproj/project.pbxproj b/4champ.xcodeproj/project.pbxproj index 23afb3a..632e394 100644 --- a/4champ.xcodeproj/project.pbxproj +++ b/4champ.xcodeproj/project.pbxproj @@ -94,6 +94,7 @@ 735A273E264FDFAD004EAC0C /* Gzip in Frameworks */ = {isa = PBXBuildFile; productRef = 735A273D264FDFAD004EAC0C /* Gzip */; }; 735A2742264FDFDE004EAC0C /* SwiftyBeaver in Frameworks */ = {isa = PBXBuildFile; productRef = 735A2741264FDFDE004EAC0C /* SwiftyBeaver */; }; 735A2746264FE01D004EAC0C /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = 735A2745264FE01D004EAC0C /* Alamofire */; }; + 739B4BF42720850200C2D69F /* RadioSessionCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 739B4BF32720850200C2D69F /* RadioSessionCell.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -208,6 +209,7 @@ 45D61F5C20E17D8F00869814 /* Alamofire.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Alamofire.framework; path = Carthage/Build/iOS/Alamofire.framework; sourceTree = ""; }; 45D61F6220E1863600869814 /* Gzip.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Gzip.framework; path = Carthage/Build/iOS/Gzip.framework; sourceTree = ""; }; 735A274D264FE9EA004EAC0C /* AmpCDModel 9 (imports).xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "AmpCDModel 9 (imports).xcdatamodel"; sourceTree = ""; }; + 739B4BF32720850200C2D69F /* RadioSessionCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioSessionCell.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -379,8 +381,8 @@ children = ( 45AA7AEC2101BDB10096D7E7 /* ModulePlayer.swift */, 45AA90F720FF36C200794147 /* Replay.m */, - 45AA90F820FF36C200794147 /* Hively */, 45AA90FE20FF36C200794147 /* Replay.h */, + 45AA90F820FF36C200794147 /* Hively */, 45AA90FF20FF36C200794147 /* OpenMPT */, ); path = Replay; @@ -477,6 +479,7 @@ 45D61F4E20E1647300869814 /* RadioModels.swift */, 45D61F4F20E1647300869814 /* RadioViewController.swift */, 45D61F5020E1647300869814 /* RadioInteractor.swift */, + 739B4BF32720850200C2D69F /* RadioSessionCell.swift */, ); path = Radio; sourceTree = ""; @@ -662,6 +665,7 @@ 451ED0B1241E8A83006CE4E8 /* PlaylistStore.swift in Sources */, 45D392A520B1DDEC00EEF4E3 /* AppDelegate.swift in Sources */, 4569AC8B21628C70000EDFDC /* UISearchBar+ActivityIndicator.swift in Sources */, + 739B4BF42720850200C2D69F /* RadioSessionCell.swift in Sources */, 4509EA49226251EF0031408D /* Playlist+CoreDataClass.swift in Sources */, 45AA911B2100EF1900794147 /* AmpVizScene.swift in Sources */, 451B6AA52204B6CC001848FA /* LocalInteractor.swift in Sources */, @@ -859,7 +863,7 @@ CODE_SIGN_ENTITLEMENTS = ampplayer.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 23; + CURRENT_PROJECT_VERSION = 27; DEVELOPMENT_TEAM = 2G92PSCBJ9; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = ../openmpt/libopenmpt; @@ -870,7 +874,7 @@ "@executable_path/Frameworks", ); LIBRARY_SEARCH_PATHS = ../openmpt; - MARKETING_VERSION = 3.3; + MARKETING_VERSION = 3.4; PRODUCT_BUNDLE_IDENTIFIER = com.boogiesoftware.ampplayer; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -888,7 +892,7 @@ CODE_SIGN_ENTITLEMENTS = ampplayer.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 23; + CURRENT_PROJECT_VERSION = 27; DEVELOPMENT_TEAM = 2G92PSCBJ9; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = ../openmpt/libopenmpt; @@ -899,7 +903,7 @@ "@executable_path/Frameworks", ); LIBRARY_SEARCH_PATHS = ../openmpt; - MARKETING_VERSION = 3.3; + MARKETING_VERSION = 3.4; PRODUCT_BUNDLE_IDENTIFIER = com.boogiesoftware.ampplayer; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/4champ/Assets.xcassets/notifications-active.imageset/Contents.json b/4champ/Assets.xcassets/notifications-active.imageset/Contents.json new file mode 100644 index 0000000..14f6563 --- /dev/null +++ b/4champ/Assets.xcassets/notifications-active.imageset/Contents.json @@ -0,0 +1,27 @@ +{ + "images": [ + { + "filename": "outline_notifications_active_white_24pt_1x.png", + "idiom": "universal", + "scale": "1x", + "size": "24x24" + }, + { + "filename": "outline_notifications_active_white_24pt_2x.png", + "idiom": "universal", + "scale": "2x", + "size": "24x24" + }, + { + "filename": "outline_notifications_active_white_24pt_3x.png", + "idiom": "universal", + "scale": "3x", + "size": "24x24" + } + ], + "info": { + "author": "xcode", + "template-rendering-intent": "template", + "version": 1 + } +} diff --git a/4champ/Assets.xcassets/notifications-active.imageset/outline_notifications_active_white_24pt_1x.png b/4champ/Assets.xcassets/notifications-active.imageset/outline_notifications_active_white_24pt_1x.png new file mode 100644 index 0000000..f4a33e4 Binary files /dev/null and b/4champ/Assets.xcassets/notifications-active.imageset/outline_notifications_active_white_24pt_1x.png differ diff --git a/4champ/Assets.xcassets/notifications-active.imageset/outline_notifications_active_white_24pt_2x.png b/4champ/Assets.xcassets/notifications-active.imageset/outline_notifications_active_white_24pt_2x.png new file mode 100644 index 0000000..2652e2f Binary files /dev/null and b/4champ/Assets.xcassets/notifications-active.imageset/outline_notifications_active_white_24pt_2x.png differ diff --git a/4champ/Assets.xcassets/notifications-active.imageset/outline_notifications_active_white_24pt_3x.png b/4champ/Assets.xcassets/notifications-active.imageset/outline_notifications_active_white_24pt_3x.png new file mode 100644 index 0000000..ba69db7 Binary files /dev/null and b/4champ/Assets.xcassets/notifications-active.imageset/outline_notifications_active_white_24pt_3x.png differ diff --git a/4champ/Assets.xcassets/notifications-add.imageset/Contents.json b/4champ/Assets.xcassets/notifications-add.imageset/Contents.json new file mode 100644 index 0000000..7728a0e --- /dev/null +++ b/4champ/Assets.xcassets/notifications-add.imageset/Contents.json @@ -0,0 +1,27 @@ +{ + "images": [ + { + "filename": "outline_notification_add_white_24pt_1x.png", + "idiom": "universal", + "scale": "1x", + "size": "24x24" + }, + { + "filename": "outline_notification_add_white_24pt_2x.png", + "idiom": "universal", + "scale": "2x", + "size": "24x24" + }, + { + "filename": "outline_notification_add_white_24pt_3x.png", + "idiom": "universal", + "scale": "3x", + "size": "24x24" + } + ], + "info": { + "author": "xcode", + "template-rendering-intent": "template", + "version": 1 + } +} diff --git a/4champ/Assets.xcassets/notifications-add.imageset/outline_notification_add_white_24pt_1x.png b/4champ/Assets.xcassets/notifications-add.imageset/outline_notification_add_white_24pt_1x.png new file mode 100644 index 0000000..18108f2 Binary files /dev/null and b/4champ/Assets.xcassets/notifications-add.imageset/outline_notification_add_white_24pt_1x.png differ diff --git a/4champ/Assets.xcassets/notifications-add.imageset/outline_notification_add_white_24pt_2x.png b/4champ/Assets.xcassets/notifications-add.imageset/outline_notification_add_white_24pt_2x.png new file mode 100644 index 0000000..431bd5f Binary files /dev/null and b/4champ/Assets.xcassets/notifications-add.imageset/outline_notification_add_white_24pt_2x.png differ diff --git a/4champ/Assets.xcassets/notifications-add.imageset/outline_notification_add_white_24pt_3x.png b/4champ/Assets.xcassets/notifications-add.imageset/outline_notification_add_white_24pt_3x.png new file mode 100644 index 0000000..fc2aa44 Binary files /dev/null and b/4champ/Assets.xcassets/notifications-add.imageset/outline_notification_add_white_24pt_3x.png differ diff --git a/4champ/Assets.xcassets/notifications-off.imageset/Contents.json b/4champ/Assets.xcassets/notifications-off.imageset/Contents.json new file mode 100644 index 0000000..445f813 --- /dev/null +++ b/4champ/Assets.xcassets/notifications-off.imageset/Contents.json @@ -0,0 +1,27 @@ +{ + "images": [ + { + "filename": "outline_notifications_off_white_24pt_1x.png", + "idiom": "universal", + "scale": "1x", + "size": "24x24" + }, + { + "filename": "outline_notifications_off_white_24pt_2x.png", + "idiom": "universal", + "scale": "2x", + "size": "24x24" + }, + { + "filename": "outline_notifications_off_white_24pt_3x.png", + "idiom": "universal", + "scale": "3x", + "size": "24x24" + } + ], + "info": { + "author": "xcode", + "template-rendering-intent": "template", + "version": 1 + } +} diff --git a/4champ/Assets.xcassets/notifications-off.imageset/outline_notifications_off_white_24pt_1x.png b/4champ/Assets.xcassets/notifications-off.imageset/outline_notifications_off_white_24pt_1x.png new file mode 100644 index 0000000..d4c64b3 Binary files /dev/null and b/4champ/Assets.xcassets/notifications-off.imageset/outline_notifications_off_white_24pt_1x.png differ diff --git a/4champ/Assets.xcassets/notifications-off.imageset/outline_notifications_off_white_24pt_2x.png b/4champ/Assets.xcassets/notifications-off.imageset/outline_notifications_off_white_24pt_2x.png new file mode 100644 index 0000000..cde7017 Binary files /dev/null and b/4champ/Assets.xcassets/notifications-off.imageset/outline_notifications_off_white_24pt_2x.png differ diff --git a/4champ/Assets.xcassets/notifications-off.imageset/outline_notifications_off_white_24pt_3x.png b/4champ/Assets.xcassets/notifications-off.imageset/outline_notifications_off_white_24pt_3x.png new file mode 100644 index 0000000..b264090 Binary files /dev/null and b/4champ/Assets.xcassets/notifications-off.imageset/outline_notifications_off_white_24pt_3x.png differ diff --git a/4champ/Base.lproj/Main.storyboard b/4champ/Base.lproj/Main.storyboard index 5ade6e8..7f191e7 100644 --- a/4champ/Base.lproj/Main.storyboard +++ b/4champ/Base.lproj/Main.storyboard @@ -1,10 +1,11 @@ - + - + + @@ -16,86 +17,71 @@ - - - - - - - - - - - + - + + + + + + + + + + + + - - - - - - - - - - - + - + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -249,21 +256,26 @@ + + - + + + + - + @@ -1004,7 +1016,7 @@ - + @@ -1015,12 +1027,12 @@ - + - + @@ -1078,7 +1090,6 @@ - @@ -1087,5 +1098,8 @@ + + + diff --git a/4champ/Info.plist b/4champ/Info.plist index 3233ba3..72f34ef 100644 --- a/4champ/Info.plist +++ b/4champ/Info.plist @@ -46,6 +46,8 @@ CFBundleVersion $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + LSRequiresIPhoneOS LSSupportsOpeningDocumentsInPlace diff --git a/4champ/Replay/ModulePlayer.swift b/4champ/Replay/ModulePlayer.swift index eed03ef..e1186e1 100644 --- a/4champ/Replay/ModulePlayer.swift +++ b/4champ/Replay/ModulePlayer.swift @@ -47,7 +47,8 @@ protocol ModulePlayerObserver: class { /// called when module changes in the player /// - parameters: /// - module: module that player changed to - func moduleChanged(module: MMD) + /// - previous: the module that player was playing at change (if any) + func moduleChanged(module: MMD, previous: MMD?) /// called if there is an error in the modulePlayer /// - parameters: @@ -63,7 +64,8 @@ class ModulePlayer: NSObject { var playQueue: [MMD] = [] let renderer = Replay() let mpImage = UIImage.init(named: "albumart")! - + weak var radioRemoteControl: RadioRemoteControl? + var currentModule: MMD? { // on currentModule change, post info on MPNowPlayingInfoCenter didSet { @@ -87,7 +89,7 @@ class ModulePlayer: NSObject { MPNowPlayingInfoCenter.default().nowPlayingInfo = dict _ = observers.map { - $0.moduleChanged(module: mod) + $0.moduleChanged(module: mod, previous: oldValue) } } } @@ -169,11 +171,12 @@ class ModulePlayer: NSObject { return false } renderer.stop() - if renderer.loadModule(path, type:playQueue[at].type) { + let mod = playQueue[at] + if renderer.loadModule(path, type:mod.type) { let settings = SettingsInteractor() setStereoSeparation(settings.stereoSeparation) setInterpolation(settings.interpolation) - currentModule = playQueue[at] + currentModule = mod renderer.play() status = .playing return true @@ -229,11 +232,17 @@ class ModulePlayer: NSObject { } /// Plays the previous module in the current playlist. The playlist index - /// will not wrap from start to end when using `playPrev()` function + /// will not wrap from start to end when using `playPrev()` function. + /// Function has custom implementation for Radio mode. func playPrev() { + guard !radioOn else { + radioRemoteControl?.playPrev() + return; + } guard let current = currentModule, playQueue.count > 0 else { return } + var prevIndex = 0 if let index = playQueue.firstIndex(of: current) { if index > 0 { @@ -242,7 +251,7 @@ class ModulePlayer: NSObject { } play(at: prevIndex) } - + /// Pauses the current playback func pause() { guard status == .playing else { return } @@ -279,6 +288,7 @@ class ModulePlayer: NSObject { } } playQueue.removeAll() + currentModule = nil } diff --git a/4champ/Replay/Replay.m b/4champ/Replay/Replay.m index 7c710e6..55f9ab5 100644 --- a/4champ/Replay/Replay.m +++ b/4champ/Replay/Replay.m @@ -12,7 +12,7 @@ #import #import -@interface Replay () { +@interface Replay () { AudioComponentInstance audioUnit; AURenderCallbackStruct callbackStruct; id renderer; diff --git a/4champ/Scenes/Local/LocalViewController.swift b/4champ/Scenes/Local/LocalViewController.swift index f4c5cb9..753805f 100644 --- a/4champ/Scenes/Local/LocalViewController.swift +++ b/4champ/Scenes/Local/LocalViewController.swift @@ -259,7 +259,7 @@ extension LocalViewController: UITableViewDataSource { // MARK: Module Player Observer extension LocalViewController: ModulePlayerObserver { - func moduleChanged(module: MMD) { + func moduleChanged(module: MMD, previous: MMD?) { // tableView?.reloadData() } diff --git a/4champ/Scenes/MainViewController.swift b/4champ/Scenes/MainViewController.swift index 57174c1..6dc7790 100644 --- a/4champ/Scenes/MainViewController.swift +++ b/4champ/Scenes/MainViewController.swift @@ -148,7 +148,7 @@ class MainViewController: UITabBarController { } extension MainViewController: ModulePlayerObserver { - func moduleChanged(module: MMD) { + func moduleChanged(module: MMD, previous: MMD?) { log.info("\(module.name!) (\(module.type!))") DispatchQueue.main.async { self.npView.setModule(module) diff --git a/4champ/Scenes/Playlists/PlaylistStore.swift b/4champ/Scenes/Playlists/PlaylistStore.swift index 89b246b..3fecd84 100644 --- a/4champ/Scenes/Playlists/PlaylistStore.swift +++ b/4champ/Scenes/Playlists/PlaylistStore.swift @@ -71,7 +71,7 @@ class PlaylistStore: ObservableObject, PlaylistDisplayLogic } extension PlaylistStore: ModulePlayerObserver { - func moduleChanged(module: MMD) { + func moduleChanged(module: MMD, previous: MMD?) { hostingController?.rootView.navigationButtonID = UUID() } diff --git a/4champ/Scenes/Radio/RadioInteractor.swift b/4champ/Scenes/Radio/RadioInteractor.swift index 8d20c2b..69b2c00 100644 --- a/4champ/Scenes/Radio/RadioInteractor.swift +++ b/4champ/Scenes/Radio/RadioInteractor.swift @@ -23,6 +23,10 @@ protocol RadioBusinessLogic /// Skips current module and starts playing the next one func playNext() + /// Goes back in current radio session history to previous tune + /// May trigger re-fetch for a module + func playPrev() + /// Refresh the new module notifications status func refreshLocalNotificationsStatus() @@ -37,6 +41,22 @@ protocol RadioBusinessLogic /// Trigger share for current module func shareCurrentModule() + + /// Get number of modules in play queue + radio session history + func getSessionLength() -> Int + + /// Get a given module in the session. + func getModule(at: IndexPath) -> MMD? + + /// Play a module from session history (play queue will not be touched) + /// - parameters: + /// - at: Specifies indexpath (=> row) in session history + func playFromSessionHistory(at: IndexPath) +} + +/// Protocol to handle play history in radio mode +protocol RadioRemoteControl: NSObjectProtocol { + func playPrev() } /// Radio datastore for keeping currently selected channel and status @@ -46,16 +66,26 @@ protocol RadioDataStore var status: RadioStatus { get set } } -class RadioInteractor: NSObject, RadioBusinessLogic, RadioDataStore +enum PostFetchAction { + case appendToQueue // default + case insertToQueue // when backstepping to session history + case startPlay // when selecting tune from session history +} + +class RadioInteractor: NSObject, RadioBusinessLogic, RadioDataStore, RadioRemoteControl { + var presenter: RadioPresentationLogic? private var lastPlayed: Int = 0 // identifier of the last module id played (used in New channel) - + private var postFetchAction: PostFetchAction = .appendToQueue // determines how to handle module at fetch complete private var ntfAuthorization: UNAuthorizationStatus = .notDetermined private var activeRequest: Alamofire.DataRequest? private var playbackTimer: Timer? + // Keep session history for getting back to modules listened in the radio mode. + private var radioSessionHistory: [MMD] = [] + private var radioOn: Bool { switch status { case .off: @@ -76,6 +106,11 @@ class RadioInteractor: NSObject, RadioBusinessLogic, RadioDataStore override init() { super.init() NotificationCenter.default.addObserver(self, selector: #selector(doBadgeUpdate(_:)), name: Notifications.badgeUpdate, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(refreshLocalNotificationsStatus), name: UIApplication.willEnterForegroundNotification, object: nil) + } + + deinit { + NotificationCenter.default.removeObserver(self) } @objc func doBadgeUpdate(_ notification: Notification?) { @@ -90,14 +125,18 @@ class RadioInteractor: NSObject, RadioBusinessLogic, RadioDataStore } guard request.powerOn == true else { modulePlayer.removePlayerObserver(self) + modulePlayer.radioRemoteControl = nil - presenter?.presentChannelBuffer(buffer: []) + presenter?.presentChannelBuffer(buffer: [], history: []) modulePlayer.cleanup() + radioSessionHistory.removeAll() return } + modulePlayer.radioRemoteControl = self modulePlayer.addPlayerObserver(self) modulePlayer.cleanup() + radioSessionHistory.removeAll() playbackTimer?.invalidate() playbackTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in @@ -122,13 +161,50 @@ class RadioInteractor: NSObject, RadioBusinessLogic, RadioDataStore modulePlayer.playNext() } - func refreshLocalNotificationsStatus() { + func playPrev() { + guard radioOn && radioSessionHistory.count > 0, let currentMod = modulePlayer.currentModule else { return } + + var nextIndex = 0 + if let currentIndex = radioSessionHistory.index(of: currentMod), currentIndex >= 0 { + nextIndex = currentIndex + 1 + } + postFetchAction = .insertToQueue + let fetcher = ModuleFetcher.init(delegate: self) + fetcher.fetchModule(ampId: radioSessionHistory[nextIndex].id!) + } + + func playFromSessionHistory(at: IndexPath) { + let historyIndex = at.row + let mod = radioSessionHistory[historyIndex] + if mod.fileExists() { + modulePlayer.play(mmd: mod) + return + } + guard mod.serviceId == .amp else { + radioSessionHistory.remove(at: historyIndex) + return; + } + postFetchAction = .startPlay + let fetcher = ModuleFetcher.init(delegate: self) + fetcher.fetchModule(ampId: radioSessionHistory[historyIndex].id!) + } + + func addToSessionHistory(module: MMD) { + if !radioSessionHistory.contains(module) { + radioSessionHistory.insert(module, at: 0) + presenter?.presentSessionHistoryInsert() + } + } + + + @objc func refreshLocalNotificationsStatus() { log.debug("") let un = UNUserNotificationCenter.current() un.getNotificationSettings { (settings) in self.ntfAuthorization = settings.authorizationStatus let response = Radio.LocalNotifications.Response( - notificationsEnabled: self.ntfAuthorization == .authorized) + notificationsEnabled: self.ntfAuthorization == .authorized, + notificationsRequested: self.ntfAuthorization != .notDetermined) self.presenter?.presentNotificationStatus(response: response) } } @@ -164,6 +240,15 @@ class RadioInteractor: NSObject, RadioBusinessLogic, RadioDataStore moduleStorage.addModule(module: mod) } + func getSessionLength() -> Int { + return radioSessionHistory.count + } + + func getModule(at: IndexPath) -> MMD? { + guard radioSessionHistory.count > at.row else { return nil } + return radioSessionHistory[at.row] + } + // MARK: private functions /// Stops current playback when radio is turned off, or channel is changed @@ -194,10 +279,10 @@ class RadioInteractor: NSObject, RadioBusinessLogic, RadioDataStore private func triggerBufferPresentation() { log.debug("") guard radioOn else { - self.presenter?.presentChannelBuffer(buffer: []) + self.presenter?.presentChannelBuffer(buffer: [], history:[]) return } - self.presenter?.presentChannelBuffer(buffer: modulePlayer.playQueue) + self.presenter?.presentChannelBuffer(buffer: modulePlayer.playQueue, history: radioSessionHistory) } /// Removes the first module in current playlist and deletes the related local file @@ -238,8 +323,13 @@ class RadioInteractor: NSObject, RadioBusinessLogic, RadioDataStore log.debug("") switch channel { case .all: - let id = arc4random_uniform(UInt32(settings.collectionSize)) - return Int(id) + var id: Int = 0 + while(id == 0 || !radioSessionHistory.filter({ mmd in + mmd.id == id + }).isEmpty) { + id = Int(arc4random_uniform(UInt32(settings.collectionSize))) + } + return id case .new: if lastPlayed == 0 { lastPlayed = settings.collectionSize @@ -287,7 +377,16 @@ extension RadioInteractor: ModuleFetcherDelegate { status = .fetching(progress: progress) case .done(let mmd): - modulePlayer.playQueue.append(mmd) + switch(postFetchAction) { + case .appendToQueue: + modulePlayer.playQueue.append(mmd) + case .insertToQueue: + modulePlayer.playQueue.insert(mmd, at: 0) + case .startPlay: + modulePlayer.play(mmd: mmd) + } + postFetchAction = .appendToQueue // reset to default + self.triggerBufferPresentation() if let first = modulePlayer.playQueue.first, first == mmd { modulePlayer.play(at: 0) @@ -301,7 +400,7 @@ extension RadioInteractor: ModuleFetcherDelegate { } extension RadioInteractor: ModulePlayerObserver { - func moduleChanged(module: MMD) { + func moduleChanged(module: MMD, previous: MMD?) { guard radioOn else { return } log.debug("") if let index = modulePlayer.playQueue.firstIndex(of: module), index > 0 { @@ -309,6 +408,9 @@ extension RadioInteractor: ModulePlayerObserver { } fillBuffer() triggerBufferPresentation() + if let previous = previous { + addToSessionHistory(module: previous) + } } func statusChanged(status: PlayerStatus) { @@ -335,8 +437,9 @@ extension RadioInteractor: ModulePlayerObserver { func queueChanged(changeType: QueueChange) { if changeType == .newPlaylist && radioOn { status = .off + radioSessionHistory.removeAll() modulePlayer.removePlayerObserver(self) - presenter?.presentChannelBuffer(buffer: []) + presenter?.presentChannelBuffer(buffer: [], history: []) presenter?.presentControlStatus(status: .off) playbackTimer?.invalidate() } else { diff --git a/4champ/Scenes/Radio/RadioModels.swift b/4champ/Scenes/Radio/RadioModels.swift index 68cc63f..d5a349b 100644 --- a/4champ/Scenes/Radio/RadioModels.swift +++ b/4champ/Scenes/Radio/RadioModels.swift @@ -60,6 +60,7 @@ enum Radio struct ViewModel { var nowPlaying: MMD? var nextUp: String? + var historyAvailable: Bool } } @@ -67,9 +68,10 @@ enum Radio struct Request {} struct Response { var notificationsEnabled: Bool + var notificationsRequested: Bool } struct ViewModel { - var buttonTitle: String + var imageName: String } } diff --git a/4champ/Scenes/Radio/RadioPresenter.swift b/4champ/Scenes/Radio/RadioPresenter.swift index 85e907c..5abda4f 100644 --- a/4champ/Scenes/Radio/RadioPresenter.swift +++ b/4champ/Scenes/Radio/RadioPresenter.swift @@ -10,12 +10,19 @@ import UIKit protocol RadioPresentationLogic { func presentControlStatus(status: RadioStatus) - func presentChannelBuffer(buffer: [MMD]) + func presentChannelBuffer(buffer: [MMD], history: [MMD]) + func presentSessionHistoryInsert() func presentPlaybackTime(length: Int, elapsed: Int) func presentNotificationStatus(response: Radio.LocalNotifications.Response) func presentNewModules(response: Radio.NewModules.Response) } +enum NotificationState { + case unknown + case enabled + case disabled +} + class RadioPresenter: RadioPresentationLogic { weak var viewController: RadioDisplayLogic? @@ -28,18 +35,23 @@ class RadioPresenter: RadioPresentationLogic } } - func presentChannelBuffer(buffer: [MMD]) { + func presentChannelBuffer(buffer: [MMD], history: [MMD]) { log.debug("") var nextUp: String? if buffer.count > 1 { - nextUp = String.init(format: "Radio_NextUp".l13n(), buffer[1].name ?? "G_untitled".l13n(), buffer[1].composer ?? "G_untitled".l13n() ) + nextUp = "Radio_NextUp".l13n() + " " + String.init(format: "Radio_NByN".l13n(), buffer[1].name ?? "G_untitled".l13n(), buffer[1].composer ?? "G_untitled".l13n() ) } - let vm = Radio.ChannelBuffer.ViewModel(nowPlaying: buffer.first, nextUp: nextUp) + let canStepBack = history.count > 0 && buffer.first != history.last + let vm = Radio.ChannelBuffer.ViewModel(nowPlaying: buffer.first, nextUp: nextUp, historyAvailable: canStepBack) DispatchQueue.main.async { self.viewController?.displayChannelBuffer(viewModel: vm) } } + + func presentSessionHistoryInsert() { + self.viewController?.displaySessionHistoryInsert() + } func presentPlaybackTime(length: Int, elapsed: Int) { let timeLeft = length - elapsed @@ -54,9 +66,14 @@ class RadioPresenter: RadioPresentationLogic func presentNotificationStatus(response: Radio.LocalNotifications.Response) { log.debug("") - var vm = Radio.LocalNotifications.ViewModel(buttonTitle: "Radio_NotificationButton".l13n()) - if response.notificationsEnabled { - vm.buttonTitle = "Radio_NotificationButton_Settings".l13n() + var vm = Radio.LocalNotifications.ViewModel(imageName: "notifications-add") + + if response.notificationsRequested { + if response.notificationsEnabled { + vm.imageName = "notifications-active" + } else { + vm.imageName = "notifications-off" + } } DispatchQueue.main.async { self.viewController?.displayLocalNotificationStatus(viewModel: vm) diff --git a/4champ/Scenes/Radio/RadioSessionCell.swift b/4champ/Scenes/Radio/RadioSessionCell.swift new file mode 100644 index 0000000..252a877 --- /dev/null +++ b/4champ/Scenes/Radio/RadioSessionCell.swift @@ -0,0 +1,50 @@ +// +// RadioSessionCell.swift +// ampplayer +// +// Created by Aleksi Sitomaniemi on 20.10.2021. +// Copyright © 2021 boogie. All rights reserved. +// + +import Foundation +import UIKit + +class RadioSessionCell: UITableViewCell { + static let ReuseId = "RadioSessionCell" + + let moduleTitle: UILabel = UILabel.init(frame: .zero); + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + textLabel?.removeFromSuperview() + + moduleTitle.translatesAutoresizingMaskIntoConstraints = false + + moduleTitle.textColor = Appearance.veryLightGray.withAlphaComponent(0.8) + let descriptor = UIFontDescriptor.init(fontAttributes: [.family : "DIN Alternate"]) + let fnt = UIFont.init(descriptor: descriptor, size: 13.0) + moduleTitle.font = fnt + + let separatorView = UIView.init(frame: .zero) + separatorView.backgroundColor = Appearance.radioSeparatorColor + separatorView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(separatorView) + contentView.addSubview(moduleTitle); + NSLayoutConstraint.activate([ + separatorView.heightAnchor.constraint(equalToConstant: 0.5), + separatorView.widthAnchor.constraint(equalTo: contentView.widthAnchor, constant: -40), + separatorView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + separatorView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + NSLayoutConstraint.activate([ + moduleTitle.widthAnchor.constraint(equalTo: contentView.widthAnchor, constant: -40), + moduleTitle.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + moduleTitle.centerYAnchor.constraint(equalTo: contentView.centerYAnchor) + ]) + } + + + required init?(coder: NSCoder) { + fatalError() + } +} diff --git a/4champ/Scenes/Radio/RadioViewController.swift b/4champ/Scenes/Radio/RadioViewController.swift index 7c68cba..10493d8 100644 --- a/4champ/Scenes/Radio/RadioViewController.swift +++ b/4champ/Scenes/Radio/RadioViewController.swift @@ -14,6 +14,7 @@ protocol RadioDisplayLogic: class func displayPlaybackTime(viewModel: Radio.Playback.ViewModel) func displayLocalNotificationStatus(viewModel: Radio.LocalNotifications.ViewModel) func displayNewModules(viewModel: Radio.NewModules.ViewModel) + func displaySessionHistoryInsert() } class RadioViewController: UIViewController, RadioDisplayLogic @@ -29,6 +30,7 @@ class RadioViewController: UIViewController, RadioDisplayLogic @IBOutlet weak var switchTitle: UILabel? @IBOutlet weak var nextUpTitle: UILabel? + @IBOutlet weak var historyTitle: UILabel? @IBOutlet weak var nameLabel: UILabel? @IBOutlet weak var composerLabel: UILabel? @@ -40,9 +42,25 @@ class RadioViewController: UIViewController, RadioDisplayLogic @IBOutlet weak var faveButton: UIButton? @IBOutlet weak var shareButton: UIButton? - @IBOutlet weak var notifyButton: UIButton? @IBOutlet weak var radioSwitch: UISwitch? + @IBOutlet weak var prevButton: UIButton? + @IBOutlet weak var nextButton: UIButton? + @IBOutlet weak var tableDivider: UIView? // view between table currentModuleView + @IBOutlet weak var radioTable: UITableView? + @IBOutlet weak var tableBottomConstraint: NSLayoutConstraint? + + var notifyItem: UIBarButtonItem? + + private let gradientLayer = CAGradientLayer() + let gradientColorTop = UIColor.init(rgb: 0x16538a) + let gradientColorBottom = UIColor.init(rgb: 0x16538a) + + enum GradientAnimationDirection: String { + case none + case `in` = "colorIn" + case out = "colorOut" + } // MARK: Object lifecycle override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) @@ -102,7 +120,7 @@ class RadioViewController: UIViewController, RadioDisplayLogic shareButton?.isHidden = true faveButton?.isHidden = false // saveButton?.isHidden = true - notifyButton?.isHidden = true + channelSegments?.setTitle("Radio_All".l13n(), forSegmentAt: 0) channelSegments?.setTitle("Radio_New".l13n(), forSegmentAt: 1) @@ -110,13 +128,28 @@ class RadioViewController: UIViewController, RadioDisplayLogic channelSegments?.setTitleTextAttributes([NSAttributedString.Key.foregroundColor: UIColor.black], for: .selected) channelSegments?.setTitleTextAttributes([NSAttributedString.Key.foregroundColor: UIColor.white], for: .normal) - + downloadProgress?.trackTintColor = Appearance.radioSeparatorColor + downloadProgress?.progressViewStyle = .default + interactor?.refreshLocalNotificationsStatus() interactor?.refreshBadge() - displayChannelBuffer(viewModel: Radio.ChannelBuffer.ViewModel(nowPlaying: nil, nextUp: nil)) + displayChannelBuffer(viewModel: Radio.ChannelBuffer.ViewModel(nowPlaying: nil, nextUp: nil, historyAvailable: false)) + + radioTable?.dataSource = self + radioTable?.delegate = self + radioTable?.separatorStyle = .none + radioTable?.register(RadioSessionCell.self, forCellReuseIdentifier: RadioSessionCell.ReuseId) + tableDivider?.backgroundColor = Appearance.radioSeparatorColor + tableDivider?.layer.cornerRadius = 1.5 + + setupGradientBackground() let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(longPressed(sender:))) currentModuleView?.addGestureRecognizer(longPressRecognizer) + + let img = UIImage(named: "notifications-add")?.withRenderingMode(.alwaysTemplate) + notifyItem = UIBarButtonItem.init(image: img, landscapeImagePhone: img, style: .plain, target: self, action: #selector(notificationsPressed)) + self.navigationItem.rightBarButtonItem = notifyItem } @objc func longPressed(sender: UILongPressGestureRecognizer) { @@ -127,11 +160,52 @@ class RadioViewController: UIViewController, RadioDisplayLogic } } + override func viewWillLayoutSubviews() { + super.viewWillLayoutSubviews() + gradientLayer.frame = CGRect.init(x: 0, y: 0, width: view.frame.width, height: currentModuleView?.frame.height ?? 100) + } + + @objc func notificationsPressed(sender: UINavigationItem) { + interactor?.requestLocalNotifications() + } + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) } - // MARK: Do something + + func setupGradientBackground() { + gradientLayer.colors = [gradientColorTop.withAlphaComponent(0).cgColor, gradientColorBottom.withAlphaComponent(0).cgColor] + gradientLayer.locations = [0.0, 1.0] + gradientLayer.drawsAsynchronously = true + gradientLayer.frame = CGRect.init(x: 0, y: 0, width: view.frame.width, height: currentModuleView?.frame.height ?? 100) + currentModuleView?.layer.insertSublayer(gradientLayer, at:0) + } + + func animateGradient(_ direction: GradientAnimationDirection) { + guard gradientLayer.animation(forKey: direction.rawValue) == nil else { return } + gradientLayer.removeAnimation(forKey: GradientAnimationDirection.in.rawValue) + gradientLayer.removeAnimation(forKey: GradientAnimationDirection.out.rawValue) + + let startAlphas: [CGFloat] = direction == .in ? [0, 0] : [1,0.4] + gradientLayer.colors = [gradientColorTop.withAlphaComponent(startAlphas[0]).cgColor, gradientColorBottom.withAlphaComponent(startAlphas[1]).cgColor] + + let gradientChangeAnimation = CABasicAnimation(keyPath: "colors") + gradientChangeAnimation.duration = 0.5 + + let endAlphas: [CGFloat] = direction == .in ? [1, 0.4] : [0,0] + + gradientChangeAnimation.toValue = [ + gradientColorTop.withAlphaComponent(endAlphas[0]).cgColor, + gradientColorBottom.withAlphaComponent(endAlphas[1]).cgColor + ] + gradientChangeAnimation.fillMode = CAMediaTimingFillMode.forwards + gradientChangeAnimation.isRemovedOnCompletion = false + gradientLayer.add(gradientChangeAnimation, forKey: direction.rawValue) + } + + + func displayControlStatus(viewModel: Radio.Control.ViewModel) { switch viewModel.status { case .on: @@ -165,13 +239,16 @@ class RadioViewController: UIViewController, RadioDisplayLogic if let nextUp = viewModel.nextUp { nextUpTitle?.text = nextUp } else { - nextUpTitle?.text = "" + nextUpTitle?.text = radioSwitch?.isOn ?? false ? " " : "" // just to keep the space open when radio is on } + prevButton?.isEnabled = viewModel.historyAvailable + nextButton?.isEnabled = viewModel.nowPlaying != nil + if let current = viewModel.nowPlaying { currentModule = current currentModuleView?.alpha = 1 - composerLabel?.text = current.composer ?? "" + composerLabel?.text = current.composer nameLabel?.text = current.name ?? "" sizeLabel?.text = "\(current.size ?? 0) kb" localLabel?.isHidden = !current.hasBeenSaved() @@ -179,6 +256,8 @@ class RadioViewController: UIViewController, RadioDisplayLogic saveButton?.isHidden = current.hasBeenSaved() // For now, hide the share button (sharing through the long-tap menu) shareButton?.isHidden = true + animateGradient(.in) + tableBottomConstraint?.constant = 50.0 } else { currentModule = nil currentModuleView?.alpha = 0.8 @@ -190,6 +269,10 @@ class RadioViewController: UIViewController, RadioDisplayLogic faveButton?.isSelected = false saveButton?.isHidden = false shareButton?.isHidden = true + tableBottomConstraint?.constant = 0 + radioTable?.reloadData() + animateGradient(.out) + historyTitle?.text = "" } } @@ -199,8 +282,7 @@ class RadioViewController: UIViewController, RadioDisplayLogic func displayLocalNotificationStatus(viewModel: Radio.LocalNotifications.ViewModel) { log.debug("") - notifyButton?.setTitle(viewModel.buttonTitle, for: .normal) - notifyButton?.isHidden = false + notifyItem?.image = UIImage(named: viewModel.imageName) } func displayNewModules(viewModel: Radio.NewModules.ViewModel) { @@ -217,6 +299,11 @@ class RadioViewController: UIViewController, RadioDisplayLogic } } + func displaySessionHistoryInsert() { + historyTitle?.text = "Radio_Previous".l13n() + radioTable?.insertRows(at: [IndexPath.init(item: 0, section: 0)], with: .top) + } + @IBAction private func saveTapped(_ sender: UIButton) { interactor?.saveCurrentModule() } @@ -225,14 +312,14 @@ class RadioViewController: UIViewController, RadioDisplayLogic interactor?.shareCurrentModule() } - @IBAction private func ntfButtonTapped(_ sender: UIButton) { - interactor?.requestLocalNotifications() - } - @IBAction private func nextTapped(_ sender: UIButton) { interactor?.playNext() } + @IBAction private func prevTapped(_ sender: UIButton) { + interactor?.playPrev(); + } + @IBAction private func controlSwitchChanged(_ sender: UISwitch) { log.debug("") if let channelSelection = RadioChannel(rawValue: channelSegments?.selectedSegmentIndex ?? 0) { @@ -253,3 +340,36 @@ class RadioViewController: UIViewController, RadioDisplayLogic } } } + +extension RadioViewController: UITableViewDataSource, UITableViewDelegate { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return interactor?.getSessionLength() ?? 0 + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + if let cell = tableView.dequeueReusableCell(withIdentifier: RadioSessionCell.ReuseId) as? RadioSessionCell, let mod = interactor?.getModule(at: indexPath) { + let modName = mod.name ?? "n/a" + let composer = mod.composer ?? "n/a" + + let title = String.init(format: "Radio_NByN".l13n(), modName.trimmingCharacters(in: .whitespaces), composer) + cell.moduleTitle.text = title + return cell + } else { + return UITableViewCell() + } + } + + func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + log.debug("foo") + return indexPath + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + interactor?.playFromSessionHistory(at: indexPath) + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return 40 + } +} + diff --git a/4champ/Scenes/Search/SearchViewController.swift b/4champ/Scenes/Search/SearchViewController.swift index 57ce3c3..eb949be 100644 --- a/4champ/Scenes/Search/SearchViewController.swift +++ b/4champ/Scenes/Search/SearchViewController.swift @@ -427,7 +427,7 @@ extension SearchViewController: UITableViewDelegate { // MARK: Module Player Observer extension SearchViewController: ModulePlayerObserver { - func moduleChanged(module: MMD) { + func moduleChanged(module: MMD, previous: MMD?) { tableView?.reloadData() } diff --git a/4champ/Scenes/Visualizer/VisualizerViewController.swift b/4champ/Scenes/Visualizer/VisualizerViewController.swift index 1081496..818c2fd 100644 --- a/4champ/Scenes/Visualizer/VisualizerViewController.swift +++ b/4champ/Scenes/Visualizer/VisualizerViewController.swift @@ -347,7 +347,7 @@ class VisualizerViewController: UIViewController, UIScrollViewDelegate, UIGestur } extension VisualizerViewController: ModulePlayerObserver { - func moduleChanged(module: MMD) { + func moduleChanged(module: MMD, previous: MMD?) { log.debug("") DispatchQueue.main.async { self.updateView(module: module) diff --git a/4champ/Utils/Appearance.swift b/4champ/Utils/Appearance.swift index 75f65ff..cb40ea0 100644 --- a/4champ/Utils/Appearance.swift +++ b/4champ/Utils/Appearance.swift @@ -25,6 +25,7 @@ class Appearance { static var barTitleColor = UIColor.init(rgb: 0xc0c0c0) static var veryLightGray = UIColor.init(rgb: 0xf0f0f0) static var cellColor = UIColor.clear + static var radioSeparatorColor = UIColor.init(rgb: 0x294976) class func setup() { let navigationBarAppearace = UINavigationBar.appearance() diff --git a/4champ/l13n/da.lproj/Localizable.strings b/4champ/l13n/da.lproj/Localizable.strings index d1e2051..ecd325d 100644 Binary files a/4champ/l13n/da.lproj/Localizable.strings and b/4champ/l13n/da.lproj/Localizable.strings differ diff --git a/4champ/l13n/de.lproj/Localizable.strings b/4champ/l13n/de.lproj/Localizable.strings index 28482cf..718eb08 100644 Binary files a/4champ/l13n/de.lproj/Localizable.strings and b/4champ/l13n/de.lproj/Localizable.strings differ diff --git a/4champ/l13n/en.lproj/Localizable.strings b/4champ/l13n/en.lproj/Localizable.strings index cf2743d..1524b18 100644 Binary files a/4champ/l13n/en.lproj/Localizable.strings and b/4champ/l13n/en.lproj/Localizable.strings differ diff --git a/4champ/l13n/es.lproj/Localizable.strings b/4champ/l13n/es.lproj/Localizable.strings index 619ef29..bbb325e 100644 Binary files a/4champ/l13n/es.lproj/Localizable.strings and b/4champ/l13n/es.lproj/Localizable.strings differ diff --git a/4champ/l13n/fi.lproj/Localizable.strings b/4champ/l13n/fi.lproj/Localizable.strings index 5bbd3b2..e8133f0 100644 Binary files a/4champ/l13n/fi.lproj/Localizable.strings and b/4champ/l13n/fi.lproj/Localizable.strings differ diff --git a/4champ/l13n/nb.lproj/Localizable.strings b/4champ/l13n/nb.lproj/Localizable.strings index 7d59ebc..4be2211 100644 Binary files a/4champ/l13n/nb.lproj/Localizable.strings and b/4champ/l13n/nb.lproj/Localizable.strings differ diff --git a/4champ/l13n/ru.lproj/Localizable.strings b/4champ/l13n/ru.lproj/Localizable.strings index 88fd575..d63839e 100644 Binary files a/4champ/l13n/ru.lproj/Localizable.strings and b/4champ/l13n/ru.lproj/Localizable.strings differ diff --git a/docs/app_history.md b/docs/app_history.md index e0a2be6..cc3e4e8 100644 --- a/docs/app_history.md +++ b/docs/app_history.md @@ -114,3 +114,7 @@ Adopted Swift Package Manager. ### Version 3.3 August 2021 Support for importing module files from local file system / network drive / cloud. + +### Version 3.4 Octover 2021 + +Radio session play history added to enable moving back in the session. diff --git a/docs/journal.md b/docs/journal.md index ee52e85..0142145 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -1,5 +1,9 @@ # 4champ Development Journal +### 27 Oct 2021 We need to go back + +A couple of months ago 4champ got a review in AppStore where the reviewer stated that the only thing he/she was missing was a way to see the previously played tracks when listening to 4champ radio. I had thought about something similar myself, and this gave me a good motivation kick to put it next in the backlog. Well now it is implmented in the app! + ### 10 Aug 2021 Modules, modules everywhere It has been requested by users occasionally if it would be possible to import your own modules to 4champ. Well now it is! 4champ now works as a share target for the supported module types, or if you want to get a bunch of files in at once, you can trigger module import from either local modules tab, or playlist tab. After the files have been imported, you can assign a composer name in case you'd like. diff --git a/docs/plantuml/replayer.plantuml b/docs/plantuml/replayer.plantuml new file mode 100644 index 0000000..cf3d71c --- /dev/null +++ b/docs/plantuml/replayer.plantuml @@ -0,0 +1,89 @@ +@startuml Replayer +allowmixing + + +legend left + |Color| Type | + |<#business>| 4champ | + |<#lightgreen>| iOS Core Audio | + |<#lightblue>| Third party | +endlegend + + +class Replay <> { + -audioUnit: AudioComponentInstance + -renderer<> + initAudio() + play() + stop() + pause() + resume() + - playbackCallback() +} + +interface ReplayInformation { + currentPosition() + moduleLength() + volumeOnChannel(int) + numberOfChannels() + samples() + instruments() +} + +interface ReplayControl { + loadModule(path) + setCurrentPosition(int) + setStereoSeparation(int) + setInterpolationFilterLength(int) +} + +interface ReplayerStream { + readFrames(count, bufLeft, bufRight) +} + +class MTPReplayer <> implements ReplayerStream { + +} + +class HVLReplayer <> implements ReplayerStream { + +} + +interface ReplayStreamDelegate { + reachedEndOfStream(Replayer) +} + +interface ReplayStatusDelegate { + playStatusChanged(Replayer) +} +interface AURenderCallback #lightgreen { + callbackFunction +} + +Replay o-- HVLReplayer +Replay o-- MTPReplayer +Replay --> ReplayerStream + +App --> Replay +App -.-|> ReplayStreamDelegate +App -.-|> ReplayStatusDelegate +Replay -up-> ReplayStreamDelegate +Replay -up-> ReplayStatusDelegate + +Replay -.left--|> ReplayInformation +Replay -.left--|> ReplayControl +Replay -.right-|> AURenderCallback +AudioComponentInstance --> AURenderCallback + +package libopenmpt #lightblue { + +} +package hvl_replay #lightblue { +} + +class AudioComponentInstance #lightgreen + +Replay *-right-> AudioComponentInstance +MTPReplayer --> libopenmpt +HVLReplayer --> hvl_replay +@enduml \ No newline at end of file