From 7d8d020779393419acb76d692e27a0eca00537cd Mon Sep 17 00:00:00 2001 From: Aleksi Sitomaniemi Date: Mon, 8 Nov 2021 14:12:01 +0200 Subject: [PATCH] Radio history (#12) * Started RadioHistory implementation * Radio history feature tuning + some eye candy in the radio display * Added ITSAppUsesNonExemptEncryption = NO to plist * fixed crash when quickly switching radio channels while downloads are going on * RadioSessionCell title label added with constraints + prepping imagesets for notifications * Notification settings control through UIBarButton + better radio session history implementation * Remove radio session history when playlist changes * Localisation changes + fixed issue with playFromSessionHistory API when in local collection mode * Some final adjustments with new radio view layout * Bump build number to 27 * Updated Journal and AppHistory pages for 3.4 release --- 4champ.xcodeproj/project.pbxproj | 14 +- .../Contents.json | 27 ++ ...ine_notifications_active_white_24pt_1x.png | Bin 0 -> 305 bytes ...ine_notifications_active_white_24pt_2x.png | Bin 0 -> 563 bytes ...ine_notifications_active_white_24pt_3x.png | Bin 0 -> 851 bytes .../notifications-add.imageset/Contents.json | 27 ++ ...outline_notification_add_white_24pt_1x.png | Bin 0 -> 199 bytes ...outline_notification_add_white_24pt_2x.png | Bin 0 -> 324 bytes ...outline_notification_add_white_24pt_3x.png | Bin 0 -> 448 bytes .../notifications-off.imageset/Contents.json | 27 ++ ...utline_notifications_off_white_24pt_1x.png | Bin 0 -> 264 bytes ...utline_notifications_off_white_24pt_2x.png | Bin 0 -> 437 bytes ...utline_notifications_off_white_24pt_3x.png | Bin 0 -> 609 bytes 4champ/Base.lproj/Main.storyboard | 260 +++++++++--------- 4champ/Info.plist | 2 + 4champ/Replay/ModulePlayer.swift | 24 +- 4champ/Replay/Replay.m | 2 +- 4champ/Scenes/Local/LocalViewController.swift | 2 +- 4champ/Scenes/MainViewController.swift | 2 +- 4champ/Scenes/Playlists/PlaylistStore.swift | 2 +- 4champ/Scenes/Radio/RadioInteractor.swift | 127 ++++++++- 4champ/Scenes/Radio/RadioModels.swift | 4 +- 4champ/Scenes/Radio/RadioPresenter.swift | 31 ++- 4champ/Scenes/Radio/RadioSessionCell.swift | 50 ++++ 4champ/Scenes/Radio/RadioViewController.swift | 146 +++++++++- .../Scenes/Search/SearchViewController.swift | 2 +- .../Visualizer/VisualizerViewController.swift | 2 +- 4champ/Utils/Appearance.swift | 1 + 4champ/l13n/da.lproj/Localizable.strings | Bin 26390 -> 26492 bytes 4champ/l13n/de.lproj/Localizable.strings | Bin 27776 -> 27884 bytes 4champ/l13n/en.lproj/Localizable.strings | Bin 27790 -> 27896 bytes 4champ/l13n/es.lproj/Localizable.strings | Bin 28822 -> 28928 bytes 4champ/l13n/fi.lproj/Localizable.strings | Bin 27718 -> 27824 bytes 4champ/l13n/nb.lproj/Localizable.strings | Bin 27152 -> 27258 bytes 4champ/l13n/ru.lproj/Localizable.strings | Bin 27792 -> 27900 bytes docs/app_history.md | 4 + docs/journal.md | 4 + docs/plantuml/replayer.plantuml | 89 ++++++ 38 files changed, 675 insertions(+), 174 deletions(-) create mode 100644 4champ/Assets.xcassets/notifications-active.imageset/Contents.json create mode 100644 4champ/Assets.xcassets/notifications-active.imageset/outline_notifications_active_white_24pt_1x.png create mode 100644 4champ/Assets.xcassets/notifications-active.imageset/outline_notifications_active_white_24pt_2x.png create mode 100644 4champ/Assets.xcassets/notifications-active.imageset/outline_notifications_active_white_24pt_3x.png create mode 100644 4champ/Assets.xcassets/notifications-add.imageset/Contents.json create mode 100644 4champ/Assets.xcassets/notifications-add.imageset/outline_notification_add_white_24pt_1x.png create mode 100644 4champ/Assets.xcassets/notifications-add.imageset/outline_notification_add_white_24pt_2x.png create mode 100644 4champ/Assets.xcassets/notifications-add.imageset/outline_notification_add_white_24pt_3x.png create mode 100644 4champ/Assets.xcassets/notifications-off.imageset/Contents.json create mode 100644 4champ/Assets.xcassets/notifications-off.imageset/outline_notifications_off_white_24pt_1x.png create mode 100644 4champ/Assets.xcassets/notifications-off.imageset/outline_notifications_off_white_24pt_2x.png create mode 100644 4champ/Assets.xcassets/notifications-off.imageset/outline_notifications_off_white_24pt_3x.png create mode 100644 4champ/Scenes/Radio/RadioSessionCell.swift create mode 100644 docs/plantuml/replayer.plantuml 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 0000000000000000000000000000000000000000..f4a33e44429b9baf64a070c333e727c0ce5d31b1 GIT binary patch literal 305 zcmV-10nYx3P)&k@)s-JB-9(J{2QXR<1~#$yCk%(lM%@i=Ae&9aV+IGm0HT5s!(2s_pKt zML*d;5>$H$sy*vH=~nzg28ueU_b)7HXjmBRjDC3sPkB7Wiql9}00000NkvXXu0mjf D@tB2W literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2652e2f6b12c7ad925f76971afb32ae1d6131384 GIT binary patch literal 563 zcmV-30?hr1P)`3~i>OzTiK9zC> zwUTegtUf?g?Yg^`B!7$s_W~WDvOR6|7()q8C}EtcT5(4O z&qz5398h$D7BX|FhIuMma7kv@dHk=G43f##pC)k9=n9p->_ z3Qk$KiU&_yib+Ha+K6CE^6_jXTOhM)XJ3bFwui~A$Po?L4roBmc!kV}woi=1sWki8 zEFxO2oA5|x+3`DDrs5Gz=O!)6tf64M-Y~KUtqwKlGj`ZFW^P zQ}yKkC&zMbeoYq!gaKi|&j%puH^BTq(4)PN0Phicd!NV#Yws-rU&QYR{Na}>)vyxk z_Xp$+0r~F_h~rM$-yt5vBE`ilKHf6(f!K!#4DVi=i#aqvFY5pR002ovPDHLkV1kHm B{?z~g literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ba69db786baf155ea899237da903223951910cf3 GIT binary patch literal 851 zcmV-Z1FZasP)dU+EF4ISB&~&uX_pIk(ISFe1s0$HB6dllCi)c9KeayT!Po1dypWXSm_c!OAd*0)QLPkbLM*c+|5hB!` z)^=^oQ|;<4CB`4cD0PiH&m3*Xfpd!_@WTy>Nu(xSqRz1lB7gVxl&ns4eCyNfmL(wQO)MvZt2W zpw%JD15hYL3+pOoms-C#Wqegjta8B;Q9wHrY}~G5^^WynEz(E1$}XqR)?xdym*`Z!uFOCmMfj|$+$YN{8L=MjR1c1@YdPep zZXu6(;d!44r&_h0tj}Gw#g5aM6w$5fM8Q3m8XZ!Z9_XP6{z+cSUd)y{J0kg#AOhC5qH35nq5ztN9W31QcdLWK9cCy{h(E z_a$0H;JUC^sLty4Jn_5LfUOXo4dH2WUtm~-y%zQ>>Md318K_Xb@x;XEji)Z+g$R4# z^2{fFy`<#_?wHG8f9{_C&gIBYrTZdG3nafNr-Z#j*x$OR&xx=`VVASF!52`2oa@5g z=sbR{=|ey?u_PvE<1obLPWyydM|#^P7Z#GZxqtzY7NL-9n^B)*3&U(>gqtgblzNqXVmg7e>*ZqBhr@ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..431bd5f93245a9817f32a2fccbf2b81d819a0dfe GIT binary patch literal 324 zcmV-K0lWT*P)guq^4%j6J(Amj#n5wW++R6#5(RMf|2GUUg& z88ZVK=4t2s-sj`%#`7Hi2m&$$f{eh9Ag3m`BPf-iv?FL>zI@45Q$Wn2x$=-}-s$qf z4M!&GFL~;f*HoOevib)k;d8Jaww<)DfHfbg?4FcU76hCVbJwre77+3%Ctiv1TW>xW z-%MxQz7kB9<+ps-T!u*MhG W7pj`d=ioU20000Yym0KU^j&&K#ArE{K)Hh9zNY4_pON(i}us0jb66b{NDrld~ z7sThR@ksar2iziv6*_S_7C|iYJO@2354thG4x+z7_CU|Z75hm5y%|?*Cjs qm%Q{~J5*`m;?kljx;vVth3W@c%P&0llkdp@00005S literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d4c64b331bcaec68f870d8507aabd964f461769e GIT binary patch literal 264 zcmV+j0r&oiP)3=j3GT~Rm{yzoIj{Xk<(fBp211jkQvsnMn1#6i5p97x;um3+FTK+TspYb08 z-uzD_R!jE(p8w~-no96#NdhbBgY$F$|NH;*zYJ~-9Z;trv@8Vjx^Zi`2$XJyISedf z2V|eZt>FVunj7I4poj>N^%l1VAVBp#kUaw;hF1f6AQPbhRSQuXP#s2;27+4t*Q09y zYx$2u3q(0i(1+DIBx)g$EWiF&!>!;E*vv;LS{(72=<)wC5PA$u0RaHc0UguX!LJ$s O0000A`uA!IDjAl5D-EDK@iRgTLp@xo?ziM zM&lj(y}Qhh0pR}_^Uc@wnwy)O!zT7{aQy6HvwGAvHo6gEU%&mz@t5!!uf`y-)^A-s z9*jX4s0#2-Y!HEMJTTmSEL#JX_%a58b9n9ly}{M9jyS@dvyOm=rx~%p7OvnPKI-=u zw@x9VA2x6oM`GV_u_fXuj7P+99e>20wHm*`cbJHHBK8yKTL5kd_ZE{SFvUGbi(E47e4$JyO2F%u@L*!0ubs#DMa{Vmjm+G zj(Wg!L_J_Sq8>02Q4g4is0ZW`^?-Ur9#DiR0F)x|C=Vz`#PL%B0U?jbk8e>#)Q>Mh zsBaoVeGyS#`gD5SrK?AL!cNyBtPQtLATs0gVP&{g;!W&yEyFGj^*h9Fm#hM@!ol&g fI+^ivb94UyvPA{p{7wIp00000NkvXXu0mjfeOAEg literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b26409028ce055c505fe47667a3ade4c3dcd1c3b GIT binary patch literal 609 zcmV-n0-pVeP)`Z9A4vJ0wgu@0p5ZzA*usf?S}{qfa2^k^XLP?> z@g@xm(|C*{!~2X=H6VQ$8z~d6o*BG4P@ z9+ujZuqfTzCIWqt?nIW)}R6kH*r22shBh?R77^!}s!bnvC1yTkmkTO7llmQB)3{W6t zfC4E4)OsZJGC=J`LJu-P?LI}-?}YdZ3Gu_NCOx}7$LIqVwV vF6KII4qdEZ3j++Wg%!r1oG6N-D2o073*}wcxcsRG00000NkvXXu0mjfeS`fS literal 0 HcmV?d00001 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 d1e2051dbc3eaa4b3db8dfbaf2d2cddf1f710348..ecd325d9348aae3230ce02d6adc519ffe8380119 100644 GIT binary patch delta 112 zcmbPsj`7bq#tqZ7W0e@J8MuH@i6Mv~ks*a4lOdlWo*{sth#{4s49qKKCCO%^CnV{1%4* delta 18 acmex!j&a&K#tqZ7C+`yA+03C+VhsRP{0E@` diff --git a/4champ/l13n/de.lproj/Localizable.strings b/4champ/l13n/de.lproj/Localizable.strings index 28482cfe87b2fd3e9d61e54719886d76274decf2..718eb0804eeb04d319e6f604ffd4518db8e9f0ce 100644 GIT binary patch delta 82 zcmZp;$@u0b;|4X|$$a`^x&aJD45bjG!Xew;Z(3P+U08I!Jy#N3J delta 24 gcmaEJld<6@;|4WdMuo|V>Y9@u7)fltqAOt!0Db`pQ2+n{ diff --git a/4champ/l13n/en.lproj/Localizable.strings b/4champ/l13n/en.lproj/Localizable.strings index cf2743d01ba0322dbbb5777bc1d0d013cb2c9ff3..1524b187508a55c9cc41d76d2ecdb48fc284d754 100644 GIT binary patch delta 73 zcmeCX$@t?Y;|3-D$@h%K_yZV<7*ZL^7&0018A=(7Ctpk#We2kutR~+zmSlBesATY) bd{Rq!vyDCr)8r+3B9q_g3vEs@)UpNuel8bT delta 37 vcmV+=0NVff*#VB(0k9%30U(oqDJPSxE*P`QFWv!@rY;hb-Y^og&M_)GC@c>W diff --git a/4champ/l13n/es.lproj/Localizable.strings b/4champ/l13n/es.lproj/Localizable.strings index 619ef291a7f3917c749d51f106f3b4df4d7681f1..bbb325ebb84adcc8b44be45d4a9c11ce405b6044 100644 GIT binary patch delta 120 zcmbRCkg?$r@Vn}5u1M^B5ih;5U47Nb5 l#Nfz~$4~;4D+0?EF<1e`u$kb;;KWeL;D^w;IagQH833u36~6!g delta 14 VcmZp8#5nCC{+qF{Cn-F=R62Gn6tEGbk}AFxUdI5`!y43Q#5o2s0V- hfUG8{KV6X*ZC58~7SPDZ9kW2@v b0I9N?JW*GY)rp~!!EbV`y7K0G+D3K&S^yL{ delta 18 acmex$g>k|a#tlcbC!Y}D*=(ibUlx`N(tG5FHq2&@E delta 20 ccmex!lX1dL#ti~`j0%${YD#XNrk7?109)1v(EtDd 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