diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9f199c8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+# CocoaPods
+Pods/
+
+# Xcode
+xcuserdata/
+*.xcodeproj/*
+!*.xcodeproj/project.pbxproj
+!*.xcodeproj/xcshareddata/
+!*.xcworkspace/contents.xcworkspacedata
+**/xcshareddata/WorkspaceSettings.xcsettings
diff --git a/.ruby-version b/.ruby-version
new file mode 100644
index 0000000..4e34c4d
--- /dev/null
+++ b/.ruby-version
@@ -0,0 +1 @@
+ruby-2.7.6
diff --git a/Podfile b/Podfile
new file mode 100644
index 0000000..13d2b8b
--- /dev/null
+++ b/Podfile
@@ -0,0 +1,19 @@
+platform :ios, '15.4'
+
+target 'Tivu' do
+ use_frameworks!
+
+ pod 'MobileVLCKit', '~> 3.4.0'
+
+ target 'TivuTests' do
+ inherit! :search_paths
+ end
+end
+
+post_install do |installer|
+ installer.pods_project.targets.each do |target|
+ target.build_configurations.each do |config|
+ config.build_settings.delete 'IPHONEOS_DEPLOYMENT_TARGET'
+ end
+ end
+end
diff --git a/Podfile.lock b/Podfile.lock
new file mode 100644
index 0000000..d2abac2
--- /dev/null
+++ b/Podfile.lock
@@ -0,0 +1,16 @@
+PODS:
+ - MobileVLCKit (3.4.0)
+
+DEPENDENCIES:
+ - MobileVLCKit (~> 3.4.0)
+
+SPEC REPOS:
+ trunk:
+ - MobileVLCKit
+
+SPEC CHECKSUMS:
+ MobileVLCKit: 20ccee43f7788a94ea1f67179e33d9600779dfd9
+
+PODFILE CHECKSUM: 7c3c29c7ec55e661d01e80dce27e330daf4baaa0
+
+COCOAPODS: 1.11.3
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..9a7c18f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,51 @@
+# Tivu
+
+
+
+
+
+ |
+
+
+ |
+
+
+
+
+ |
+
+
+
+## Build
+
+1. Ensure that the CocoaPods gem installed, preferably using a Ruby version manager (rbenv, rvm, asdf-ruby):
+
+```console
+$ gem install cocoapods
+```
+
+2. Download the dependencies required by the project:
+
+```console
+$ pod install
+```
+
+3. Open the project workspace with Xcode:
+
+```console
+$ open -a Xcode Tivu.xcworkspace
+```
+
+## Clarifications
+
+The reason for manually dealing with `NSBatchUpdateRequest`, `NSBatchDeleteRequest`, `mergeChanges(fromRemoteContextSave:into:)` is the following: deleting all entities from a Core Data persistence store would require the use of `NSBatchDeleteRequest`. Such operations don't trigger any Core Data save notifications since they operate at the persistence store level and not within a `NSManagedObjectContext`. When inserting new data, the view context would have duplicate data. A solution to that would be manually triggering a save notification for *all* deleted object IDs which could be quite expensive.
+Instead, the used approach, inserts new entities ensuring that they replace the existing ones (if already there), maintaining the original object ID thanks to Core Data constraints and `NSMergePolicy.mergeByPropertyObjectTrump`. This allows to manually trigger a much smaller save notification for only the delta of actually deleted objects. (All inserted objects do generate the same size of save notifications, but this is handled by Core Data and could potentially be batched.)
+
+## Known Issues
+
+- Rotating the device during playback may occasionally dismiss the video player. This appears to be an unwanted behavior of SwiftUI `fullScreenCover(isPresented:onDismiss:content:)`.
+
+To also consider during development:
+
+- Device rotation may not work when the app is started from Xcode. Restarting the app from the device fixes this.
+- The video player is not optimized for Simulator and playback is therefore choppy and may occasionally crash. Using the app on an actual device doesn't result in these issue.
diff --git a/Screenshots/channels.png b/Screenshots/channels.png
new file mode 100644
index 0000000..06664a9
Binary files /dev/null and b/Screenshots/channels.png differ
diff --git a/Screenshots/player.png b/Screenshots/player.png
new file mode 100644
index 0000000..fff2383
Binary files /dev/null and b/Screenshots/player.png differ
diff --git a/Screenshots/settings.png b/Screenshots/settings.png
new file mode 100644
index 0000000..7193240
Binary files /dev/null and b/Screenshots/settings.png differ
diff --git a/Tivu.xcodeproj/project.pbxproj b/Tivu.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..5affbd8
--- /dev/null
+++ b/Tivu.xcodeproj/project.pbxproj
@@ -0,0 +1,953 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 55;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 3D0F07DF286608F900CBA154 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D0F07DE286608F900CBA154 /* Settings.swift */; };
+ 3D0F07E128664F7E00CBA154 /* TVHeadendServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D0F07E028664F7E00CBA154 /* TVHeadendServer.swift */; };
+ 3D0F07E328664FFB00CBA154 /* URL+BasicAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D0F07E228664FFB00CBA154 /* URL+BasicAuth.swift */; };
+ 3D0F07E52866540800CBA154 /* SettingsView+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D0F07E42866540800CBA154 /* SettingsView+ViewModel.swift */; };
+ 3D149DCF284BE0F300F6F228 /* DataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D149DCE284BE0F300F6F228 /* DataManager.swift */; };
+ 3D1EDD54284CD9ED004854E2 /* Program+Formatted.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D1EDD53284CD9ED004854E2 /* Program+Formatted.swift */; };
+ 3D2201B228685CAA00C8EA68 /* URL+PathComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D2201B128685CAA00C8EA68 /* URL+PathComponents.swift */; };
+ 3D2201B828685DB500C8EA68 /* TVHeadendServerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D2201B728685DB500C8EA68 /* TVHeadendServerTests.swift */; };
+ 3D4E2391286B2B1000ABA96E /* NowPlayingPill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D4E2390286B2B1000ABA96E /* NowPlayingPill.swift */; };
+ 3D4E2393286B309200ABA96E /* CachedProgram+FetchRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D4E2392286B309200ABA96E /* CachedProgram+FetchRequests.swift */; };
+ 3D4E2395286B30AC00ABA96E /* CachedChannel+FetchRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D4E2394286B30AC00ABA96E /* CachedChannel+FetchRequests.swift */; };
+ 3D4E2397286B359100ABA96E /* NowPlayingPill+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D4E2396286B359100ABA96E /* NowPlayingPill+ViewModel.swift */; };
+ 3D4E2399286B429C00ABA96E /* Binding+Optional.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D4E2398286B429C00ABA96E /* Binding+Optional.swift */; };
+ 3D4E239B286B42F700ABA96E /* FormField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D4E239A286B42F700ABA96E /* FormField.swift */; };
+ 3D4E239F286B4F9E00ABA96E /* ChannelView+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D4E239E286B4F9E00ABA96E /* ChannelView+ViewModel.swift */; };
+ 3D4E23A1286B74AC00ABA96E /* Fetchable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D4E23A0286B74AC00ABA96E /* Fetchable.swift */; };
+ 3D4E23A3286B759D00ABA96E /* ChannelsListView+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D4E23A2286B759D00ABA96E /* ChannelsListView+ViewModel.swift */; };
+ 3D4E23A5286B75C200ABA96E /* Persistable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D4E23A4286B75C200ABA96E /* Persistable.swift */; };
+ 3D4E23A7286B7FBC00ABA96E /* DateInterval+Progress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D4E23A6286B7FBC00ABA96E /* DateInterval+Progress.swift */; };
+ 3D753E95286A80F7009E7D3B /* NSManagedObjectContext+Merge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D753E94286A80F7009E7D3B /* NSManagedObjectContext+Merge.swift */; };
+ 3D7E77CC283F7EDF00CBAA23 /* Channel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D7E77CB283F7EDF00CBAA23 /* Channel.swift */; };
+ 3D7E77CE283F7EFA00CBAA23 /* Program.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D7E77CD283F7EFA00CBAA23 /* Program.swift */; };
+ 3D7E77D2283F7F2B00CBAA23 /* Program+XMLTV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D7E77D1283F7F2B00CBAA23 /* Program+XMLTV.swift */; };
+ 3D7E77DA283F83FD00CBAA23 /* Channel+Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D7E77D9283F83FD00CBAA23 /* Channel+Persistence.swift */; };
+ 3D7E77DC283F890C00CBAA23 /* CacheManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D7E77DB283F890C00CBAA23 /* CacheManager.swift */; };
+ 3D7E77DE283F90A500CBAA23 /* CacheManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D7E77DD283F90A500CBAA23 /* CacheManagerTests.swift */; };
+ 3D7E77E02841161F00CBAA23 /* Program+Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D7E77DF2841161F00CBAA23 /* Program+Persistence.swift */; };
+ 3D944D1828423E69006E80CB /* ProgramTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D944D1728423E69006E80CB /* ProgramTests.swift */; };
+ 3D944D1B284255AE006E80CB /* ProgramCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D944D1A284255AE006E80CB /* ProgramCard.swift */; };
+ 3D944D1F284378AB006E80CB /* ChannelsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D944D1E284378AB006E80CB /* ChannelsListView.swift */; };
+ 3D944D5E28438107006E80CB /* ChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D944D5D28438107006E80CB /* ChannelView.swift */; };
+ 3D944D6028439478006E80CB /* CachedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D944D5F28439478006E80CB /* CachedImage.swift */; };
+ 3D9A59EF2843F60F00D2DD66 /* AudioToolbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D9A59EE2843F60F00D2DD66 /* AudioToolbox.framework */; };
+ 3D9A59F12843F61500D2DD66 /* AVFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D9A59F02843F61500D2DD66 /* AVFoundation.framework */; };
+ 3D9A59F32843F61B00D2DD66 /* CFNetwork.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D9A59F22843F61B00D2DD66 /* CFNetwork.framework */; };
+ 3D9A59F52843F61F00D2DD66 /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D9A59F42843F61F00D2DD66 /* CoreFoundation.framework */; };
+ 3D9A59F72843F62400D2DD66 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D9A59F62843F62400D2DD66 /* CoreGraphics.framework */; };
+ 3D9A59F92843F62900D2DD66 /* CoreMedia.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D9A59F82843F62900D2DD66 /* CoreMedia.framework */; };
+ 3D9A59FB2843F62D00D2DD66 /* CoreText.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D9A59FA2843F62D00D2DD66 /* CoreText.framework */; };
+ 3D9A59FD2843F63100D2DD66 /* CoreVideo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D9A59FC2843F63100D2DD66 /* CoreVideo.framework */; };
+ 3D9A59FF2843F63800D2DD66 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D9A59FE2843F63800D2DD66 /* Foundation.framework */; };
+ 3D9A5A012843F63D00D2DD66 /* libbz2.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D9A5A002843F63D00D2DD66 /* libbz2.tbd */; };
+ 3D9A5A032843F64200D2DD66 /* libc++.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D9A5A022843F64200D2DD66 /* libc++.tbd */; };
+ 3D9A5A052843F64700D2DD66 /* libiconv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D9A5A042843F64700D2DD66 /* libiconv.tbd */; };
+ 3D9A5A072843F64C00D2DD66 /* libxml2.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D9A5A062843F64C00D2DD66 /* libxml2.tbd */; };
+ 3D9A5A0B2843F65500D2DD66 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D9A5A0A2843F65500D2DD66 /* QuartzCore.framework */; };
+ 3D9A5A0D2843F65900D2DD66 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D9A5A0C2843F65900D2DD66 /* Security.framework */; };
+ 3D9A5A0F2843F65D00D2DD66 /* VideoToolbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D9A5A0E2843F65D00D2DD66 /* VideoToolbox.framework */; };
+ 3D9A5A112843F66200D2DD66 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D9A5A102843F66200D2DD66 /* UIKit.framework */; };
+ 3D9A5A172843F95100D2DD66 /* Bridging-Header.h in Sources */ = {isa = PBXBuildFile; fileRef = 3D9A5A162843F95100D2DD66 /* Bridging-Header.h */; };
+ 3DA406372843FF4A00935D7A /* VideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DA406362843FF4A00935D7A /* VideoView.swift */; };
+ 3DA406392844022A00935D7A /* PlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DA406382844022A00935D7A /* PlayerView.swift */; };
+ 3DABDBCE2870760E0047C6DF /* Date+Within.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DABDBCD2870760E0047C6DF /* Date+Within.swift */; };
+ 3DB7A5132870608C00C9427B /* Date+Formatted.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB7A5122870608C00C9427B /* Date+Formatted.swift */; };
+ 3DE363E72843C1780004C325 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DE363E62843C1780004C325 /* SettingsView.swift */; };
+ 3DE9401F283EB6C10051FECD /* TivuApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DE9401E283EB6C10051FECD /* TivuApp.swift */; };
+ 3DE94021283EB6C10051FECD /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DE94020283EB6C10051FECD /* ContentView.swift */; };
+ 3DE94023283EB6C20051FECD /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3DE94022283EB6C20051FECD /* Assets.xcassets */; };
+ 3DE9402B283EB6C20051FECD /* Tivu.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 3DE94029283EB6C20051FECD /* Tivu.xcdatamodeld */; };
+ 3DE9404E283EB7130051FECD /* XMLTVParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DE9404D283EB7130051FECD /* XMLTVParser.swift */; };
+ 3DE94050283EB7320051FECD /* XMLTVParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DE9404F283EB7320051FECD /* XMLTVParserTests.swift */; };
+ 4A405BE7D1290C59D3DB6B09 /* Pods_Tivu.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 35E9F7A68FC872FBDFE5263C /* Pods_Tivu.framework */; };
+ AA38F01AFEA0049478C58FB9 /* Pods_TivuTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 285CFA87B8E64C1361B3B11F /* Pods_TivuTests.framework */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ 3DE94031283EB6C20051FECD /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 3DE94013283EB6C10051FECD /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 3DE9401A283EB6C10051FECD;
+ remoteInfo = Tivu;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXFileReference section */
+ 1FF043395B53FE41BDD6504D /* Pods-Tivu.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Tivu.release.xcconfig"; path = "Target Support Files/Pods-Tivu/Pods-Tivu.release.xcconfig"; sourceTree = ""; };
+ 285CFA87B8E64C1361B3B11F /* Pods_TivuTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_TivuTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ 35E9F7A68FC872FBDFE5263C /* Pods_Tivu.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Tivu.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ 3D0F07DE286608F900CBA154 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = ""; };
+ 3D0F07E028664F7E00CBA154 /* TVHeadendServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVHeadendServer.swift; sourceTree = ""; };
+ 3D0F07E228664FFB00CBA154 /* URL+BasicAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+BasicAuth.swift"; sourceTree = ""; };
+ 3D0F07E42866540800CBA154 /* SettingsView+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsView+ViewModel.swift"; sourceTree = ""; };
+ 3D149DCE284BE0F300F6F228 /* DataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataManager.swift; sourceTree = ""; };
+ 3D1EDD53284CD9ED004854E2 /* Program+Formatted.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Program+Formatted.swift"; sourceTree = ""; };
+ 3D2201B128685CAA00C8EA68 /* URL+PathComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+PathComponents.swift"; sourceTree = ""; };
+ 3D2201B728685DB500C8EA68 /* TVHeadendServerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVHeadendServerTests.swift; sourceTree = ""; };
+ 3D4E2390286B2B1000ABA96E /* NowPlayingPill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlayingPill.swift; sourceTree = ""; };
+ 3D4E2392286B309200ABA96E /* CachedProgram+FetchRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CachedProgram+FetchRequests.swift"; sourceTree = ""; };
+ 3D4E2394286B30AC00ABA96E /* CachedChannel+FetchRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CachedChannel+FetchRequests.swift"; sourceTree = ""; };
+ 3D4E2396286B359100ABA96E /* NowPlayingPill+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NowPlayingPill+ViewModel.swift"; sourceTree = ""; };
+ 3D4E2398286B429C00ABA96E /* Binding+Optional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Binding+Optional.swift"; sourceTree = ""; };
+ 3D4E239A286B42F700ABA96E /* FormField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormField.swift; sourceTree = ""; };
+ 3D4E239E286B4F9E00ABA96E /* ChannelView+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChannelView+ViewModel.swift"; sourceTree = ""; };
+ 3D4E23A0286B74AC00ABA96E /* Fetchable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fetchable.swift; sourceTree = ""; };
+ 3D4E23A2286B759D00ABA96E /* ChannelsListView+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChannelsListView+ViewModel.swift"; sourceTree = ""; };
+ 3D4E23A4286B75C200ABA96E /* Persistable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistable.swift; sourceTree = ""; };
+ 3D4E23A6286B7FBC00ABA96E /* DateInterval+Progress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DateInterval+Progress.swift"; sourceTree = ""; };
+ 3D753E94286A80F7009E7D3B /* NSManagedObjectContext+Merge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedObjectContext+Merge.swift"; sourceTree = ""; };
+ 3D7E77CB283F7EDF00CBAA23 /* Channel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Channel.swift; sourceTree = ""; };
+ 3D7E77CD283F7EFA00CBAA23 /* Program.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Program.swift; sourceTree = ""; };
+ 3D7E77D1283F7F2B00CBAA23 /* Program+XMLTV.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Program+XMLTV.swift"; sourceTree = ""; };
+ 3D7E77D9283F83FD00CBAA23 /* Channel+Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Channel+Persistence.swift"; sourceTree = ""; };
+ 3D7E77DB283F890C00CBAA23 /* CacheManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheManager.swift; sourceTree = ""; };
+ 3D7E77DD283F90A500CBAA23 /* CacheManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheManagerTests.swift; sourceTree = ""; };
+ 3D7E77DF2841161F00CBAA23 /* Program+Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Program+Persistence.swift"; sourceTree = ""; };
+ 3D944D1728423E69006E80CB /* ProgramTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramTests.swift; sourceTree = ""; };
+ 3D944D1A284255AE006E80CB /* ProgramCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramCard.swift; sourceTree = ""; };
+ 3D944D1E284378AB006E80CB /* ChannelsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelsListView.swift; sourceTree = ""; };
+ 3D944D5D28438107006E80CB /* ChannelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelView.swift; sourceTree = ""; };
+ 3D944D5F28439478006E80CB /* CachedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedImage.swift; sourceTree = ""; };
+ 3D944D6128439EF7006E80CB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; };
+ 3D9A59EE2843F60F00D2DD66 /* AudioToolbox.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AudioToolbox.framework; path = System/Library/Frameworks/AudioToolbox.framework; sourceTree = SDKROOT; };
+ 3D9A59F02843F61500D2DD66 /* AVFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFoundation.framework; path = System/Library/Frameworks/AVFoundation.framework; sourceTree = SDKROOT; };
+ 3D9A59F22843F61B00D2DD66 /* CFNetwork.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CFNetwork.framework; path = System/Library/Frameworks/CFNetwork.framework; sourceTree = SDKROOT; };
+ 3D9A59F42843F61F00D2DD66 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; };
+ 3D9A59F62843F62400D2DD66 /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; };
+ 3D9A59F82843F62900D2DD66 /* CoreMedia.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreMedia.framework; path = System/Library/Frameworks/CoreMedia.framework; sourceTree = SDKROOT; };
+ 3D9A59FA2843F62D00D2DD66 /* CoreText.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreText.framework; path = System/Library/Frameworks/CoreText.framework; sourceTree = SDKROOT; };
+ 3D9A59FC2843F63100D2DD66 /* CoreVideo.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreVideo.framework; path = System/Library/Frameworks/CoreVideo.framework; sourceTree = SDKROOT; };
+ 3D9A59FE2843F63800D2DD66 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; };
+ 3D9A5A002843F63D00D2DD66 /* libbz2.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libbz2.tbd; path = usr/lib/libbz2.tbd; sourceTree = SDKROOT; };
+ 3D9A5A022843F64200D2DD66 /* libc++.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = "libc++.tbd"; path = "usr/lib/libc++.tbd"; sourceTree = SDKROOT; };
+ 3D9A5A042843F64700D2DD66 /* libiconv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libiconv.tbd; path = usr/lib/libiconv.tbd; sourceTree = SDKROOT; };
+ 3D9A5A062843F64C00D2DD66 /* libxml2.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libxml2.tbd; path = usr/lib/libxml2.tbd; sourceTree = SDKROOT; };
+ 3D9A5A082843F65000D2DD66 /* OpenGLES.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = OpenGLES.framework; path = System/Library/Frameworks/OpenGLES.framework; sourceTree = SDKROOT; };
+ 3D9A5A0A2843F65500D2DD66 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; };
+ 3D9A5A0C2843F65900D2DD66 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; };
+ 3D9A5A0E2843F65D00D2DD66 /* VideoToolbox.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = VideoToolbox.framework; path = System/Library/Frameworks/VideoToolbox.framework; sourceTree = SDKROOT; };
+ 3D9A5A102843F66200D2DD66 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
+ 3D9A5A162843F95100D2DD66 /* Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Bridging-Header.h"; sourceTree = ""; };
+ 3DA406362843FF4A00935D7A /* VideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoView.swift; sourceTree = ""; };
+ 3DA406382844022A00935D7A /* PlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerView.swift; sourceTree = ""; };
+ 3DABDBCD2870760E0047C6DF /* Date+Within.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Within.swift"; sourceTree = ""; };
+ 3DB7A5122870608C00C9427B /* Date+Formatted.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Formatted.swift"; sourceTree = ""; };
+ 3DE363E62843C1780004C325 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; };
+ 3DE9401B283EB6C10051FECD /* Tivu.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Tivu.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 3DE9401E283EB6C10051FECD /* TivuApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TivuApp.swift; sourceTree = ""; };
+ 3DE94020283EB6C10051FECD /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
+ 3DE94022283EB6C20051FECD /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ 3DE9402A283EB6C20051FECD /* Tivu.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Tivu.xcdatamodel; sourceTree = ""; };
+ 3DE94030283EB6C20051FECD /* TivuTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TivuTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 3DE9404D283EB7130051FECD /* XMLTVParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XMLTVParser.swift; sourceTree = ""; };
+ 3DE9404F283EB7320051FECD /* XMLTVParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XMLTVParserTests.swift; sourceTree = ""; };
+ 51B44C08A98D23EC80817C92 /* Pods-Tivu.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Tivu.debug.xcconfig"; path = "Target Support Files/Pods-Tivu/Pods-Tivu.debug.xcconfig"; sourceTree = ""; };
+ 6838CE8BEF9376A02C451640 /* Pods-TivuTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TivuTests.release.xcconfig"; path = "Target Support Files/Pods-TivuTests/Pods-TivuTests.release.xcconfig"; sourceTree = ""; };
+ C7C5C641085E7E335C5DA1CD /* Pods-TivuTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TivuTests.debug.xcconfig"; path = "Target Support Files/Pods-TivuTests/Pods-TivuTests.debug.xcconfig"; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 3DE94018283EB6C10051FECD /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 3D9A5A112843F66200D2DD66 /* UIKit.framework in Frameworks */,
+ 3D9A5A0F2843F65D00D2DD66 /* VideoToolbox.framework in Frameworks */,
+ 3D9A5A0D2843F65900D2DD66 /* Security.framework in Frameworks */,
+ 3D9A5A0B2843F65500D2DD66 /* QuartzCore.framework in Frameworks */,
+ 3D9A5A072843F64C00D2DD66 /* libxml2.tbd in Frameworks */,
+ 3D9A5A052843F64700D2DD66 /* libiconv.tbd in Frameworks */,
+ 3D9A5A032843F64200D2DD66 /* libc++.tbd in Frameworks */,
+ 3D9A5A012843F63D00D2DD66 /* libbz2.tbd in Frameworks */,
+ 3D9A59FF2843F63800D2DD66 /* Foundation.framework in Frameworks */,
+ 3D9A59FD2843F63100D2DD66 /* CoreVideo.framework in Frameworks */,
+ 3D9A59FB2843F62D00D2DD66 /* CoreText.framework in Frameworks */,
+ 3D9A59F92843F62900D2DD66 /* CoreMedia.framework in Frameworks */,
+ 3D9A59F72843F62400D2DD66 /* CoreGraphics.framework in Frameworks */,
+ 3D9A59F52843F61F00D2DD66 /* CoreFoundation.framework in Frameworks */,
+ 3D9A59F32843F61B00D2DD66 /* CFNetwork.framework in Frameworks */,
+ 3D9A59F12843F61500D2DD66 /* AVFoundation.framework in Frameworks */,
+ 3D9A59EF2843F60F00D2DD66 /* AudioToolbox.framework in Frameworks */,
+ 4A405BE7D1290C59D3DB6B09 /* Pods_Tivu.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 3DE9402D283EB6C20051FECD /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ AA38F01AFEA0049478C58FB9 /* Pods_TivuTests.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 3D2201B028685C6400C8EA68 /* Extensions */ = {
+ isa = PBXGroup;
+ children = (
+ 3D4E2398286B429C00ABA96E /* Binding+Optional.swift */,
+ 3D4E23A6286B7FBC00ABA96E /* DateInterval+Progress.swift */,
+ 3D753E94286A80F7009E7D3B /* NSManagedObjectContext+Merge.swift */,
+ 3D0F07E228664FFB00CBA154 /* URL+BasicAuth.swift */,
+ 3D2201B128685CAA00C8EA68 /* URL+PathComponents.swift */,
+ 3DABDBCD2870760E0047C6DF /* Date+Within.swift */,
+ );
+ path = Extensions;
+ sourceTree = "";
+ };
+ 3D2201B328685CE300C8EA68 /* Domains */ = {
+ isa = PBXGroup;
+ children = (
+ 3D2201B428685CF400C8EA68 /* TVHeadend */,
+ 3DE94051283EEA2B0051FECD /* XMLTV */,
+ );
+ path = Domains;
+ sourceTree = "";
+ };
+ 3D2201B428685CF400C8EA68 /* TVHeadend */ = {
+ isa = PBXGroup;
+ children = (
+ 3D0F07E028664F7E00CBA154 /* TVHeadendServer.swift */,
+ );
+ path = TVHeadend;
+ sourceTree = "";
+ };
+ 3D2201B528685D8600C8EA68 /* Domains */ = {
+ isa = PBXGroup;
+ children = (
+ 3D2201B628685D8F00C8EA68 /* TVHeadend */,
+ 3D7E77D8283F835A00CBAA23 /* XMLTV */,
+ );
+ path = Domains;
+ sourceTree = "";
+ };
+ 3D2201B628685D8F00C8EA68 /* TVHeadend */ = {
+ isa = PBXGroup;
+ children = (
+ 3D2201B728685DB500C8EA68 /* TVHeadendServerTests.swift */,
+ );
+ path = TVHeadend;
+ sourceTree = "";
+ };
+ 3D7E77CA283F7ECF00CBAA23 /* Models */ = {
+ isa = PBXGroup;
+ children = (
+ 3D7E77CB283F7EDF00CBAA23 /* Channel.swift */,
+ 3D7E77CD283F7EFA00CBAA23 /* Program.swift */,
+ 3D0F07DE286608F900CBA154 /* Settings.swift */,
+ );
+ path = Models;
+ sourceTree = "";
+ };
+ 3D7E77D7283F80E600CBAA23 /* Persistence */ = {
+ isa = PBXGroup;
+ children = (
+ 3DE94029283EB6C20051FECD /* Tivu.xcdatamodeld */,
+ 3D149DCE284BE0F300F6F228 /* DataManager.swift */,
+ 3D7E77DB283F890C00CBAA23 /* CacheManager.swift */,
+ 3D4E23A0286B74AC00ABA96E /* Fetchable.swift */,
+ 3D4E23A4286B75C200ABA96E /* Persistable.swift */,
+ 3DB7A51128705E3600C9427B /* Extensions */,
+ );
+ path = Persistence;
+ sourceTree = "";
+ };
+ 3D7E77D8283F835A00CBAA23 /* XMLTV */ = {
+ isa = PBXGroup;
+ children = (
+ 3DE9404F283EB7320051FECD /* XMLTVParserTests.swift */,
+ 3D944D1728423E69006E80CB /* ProgramTests.swift */,
+ );
+ path = XMLTV;
+ sourceTree = "";
+ };
+ 3D89E7C02848092F00E1F1CC /* Generic */ = {
+ isa = PBXGroup;
+ children = (
+ 3D944D5F28439478006E80CB /* CachedImage.swift */,
+ );
+ path = Generic;
+ sourceTree = "";
+ };
+ 3D944D1628422FEE006E80CB /* Persistence */ = {
+ isa = PBXGroup;
+ children = (
+ 3D7E77DD283F90A500CBAA23 /* CacheManagerTests.swift */,
+ );
+ path = Persistence;
+ sourceTree = "";
+ };
+ 3D944D192842555C006E80CB /* Views */ = {
+ isa = PBXGroup;
+ children = (
+ 3DE94020283EB6C10051FECD /* ContentView.swift */,
+ 3DB7A50F28705D2C00C9427B /* Navigation */,
+ 3DB7A50E2870432B00C9427B /* Settings */,
+ 3DB7A50D2870429A00C9427B /* Player */,
+ 3D89E7C02848092F00E1F1CC /* Generic */,
+ 3DB7A51028705D6300C9427B /* Extensions */,
+ );
+ path = Views;
+ sourceTree = "";
+ };
+ 3DB7A50D2870429A00C9427B /* Player */ = {
+ isa = PBXGroup;
+ children = (
+ 3DA406382844022A00935D7A /* PlayerView.swift */,
+ 3D4E2390286B2B1000ABA96E /* NowPlayingPill.swift */,
+ 3D4E2396286B359100ABA96E /* NowPlayingPill+ViewModel.swift */,
+ 3DA406362843FF4A00935D7A /* VideoView.swift */,
+ );
+ path = Player;
+ sourceTree = "";
+ };
+ 3DB7A50E2870432B00C9427B /* Settings */ = {
+ isa = PBXGroup;
+ children = (
+ 3DE363E62843C1780004C325 /* SettingsView.swift */,
+ 3D0F07E42866540800CBA154 /* SettingsView+ViewModel.swift */,
+ 3D4E239A286B42F700ABA96E /* FormField.swift */,
+ );
+ path = Settings;
+ sourceTree = "";
+ };
+ 3DB7A50F28705D2C00C9427B /* Navigation */ = {
+ isa = PBXGroup;
+ children = (
+ 3D944D1E284378AB006E80CB /* ChannelsListView.swift */,
+ 3D4E23A2286B759D00ABA96E /* ChannelsListView+ViewModel.swift */,
+ 3D944D5D28438107006E80CB /* ChannelView.swift */,
+ 3D4E239E286B4F9E00ABA96E /* ChannelView+ViewModel.swift */,
+ 3D944D1A284255AE006E80CB /* ProgramCard.swift */,
+ );
+ path = Navigation;
+ sourceTree = "";
+ };
+ 3DB7A51028705D6300C9427B /* Extensions */ = {
+ isa = PBXGroup;
+ children = (
+ 3D1EDD53284CD9ED004854E2 /* Program+Formatted.swift */,
+ 3DB7A5122870608C00C9427B /* Date+Formatted.swift */,
+ );
+ path = Extensions;
+ sourceTree = "";
+ };
+ 3DB7A51128705E3600C9427B /* Extensions */ = {
+ isa = PBXGroup;
+ children = (
+ 3D7E77D9283F83FD00CBAA23 /* Channel+Persistence.swift */,
+ 3D7E77DF2841161F00CBAA23 /* Program+Persistence.swift */,
+ 3D4E2394286B30AC00ABA96E /* CachedChannel+FetchRequests.swift */,
+ 3D4E2392286B309200ABA96E /* CachedProgram+FetchRequests.swift */,
+ );
+ path = Extensions;
+ sourceTree = "";
+ };
+ 3DE94012283EB6C10051FECD = {
+ isa = PBXGroup;
+ children = (
+ 3DE9401D283EB6C10051FECD /* Tivu */,
+ 3DE94033283EB6C20051FECD /* TivuTests */,
+ 3DE9401C283EB6C10051FECD /* Products */,
+ ABEA2A5C7704B596F1428687 /* Pods */,
+ BACC644465788EDE9223331A /* Frameworks */,
+ );
+ sourceTree = "";
+ };
+ 3DE9401C283EB6C10051FECD /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 3DE9401B283EB6C10051FECD /* Tivu.app */,
+ 3DE94030283EB6C20051FECD /* TivuTests.xctest */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 3DE9401D283EB6C10051FECD /* Tivu */ = {
+ isa = PBXGroup;
+ children = (
+ 3D944D6128439EF7006E80CB /* Info.plist */,
+ 3D9A5A162843F95100D2DD66 /* Bridging-Header.h */,
+ 3DE94022283EB6C20051FECD /* Assets.xcassets */,
+ 3DE9401E283EB6C10051FECD /* TivuApp.swift */,
+ 3D7E77CA283F7ECF00CBAA23 /* Models */,
+ 3D944D192842555C006E80CB /* Views */,
+ 3D7E77D7283F80E600CBAA23 /* Persistence */,
+ 3D2201B328685CE300C8EA68 /* Domains */,
+ 3D2201B028685C6400C8EA68 /* Extensions */,
+ );
+ path = Tivu;
+ sourceTree = "";
+ };
+ 3DE94033283EB6C20051FECD /* TivuTests */ = {
+ isa = PBXGroup;
+ children = (
+ 3D944D1628422FEE006E80CB /* Persistence */,
+ 3D2201B528685D8600C8EA68 /* Domains */,
+ );
+ path = TivuTests;
+ sourceTree = "";
+ };
+ 3DE94051283EEA2B0051FECD /* XMLTV */ = {
+ isa = PBXGroup;
+ children = (
+ 3DE9404D283EB7130051FECD /* XMLTVParser.swift */,
+ 3D7E77D1283F7F2B00CBAA23 /* Program+XMLTV.swift */,
+ );
+ path = XMLTV;
+ sourceTree = "";
+ };
+ ABEA2A5C7704B596F1428687 /* Pods */ = {
+ isa = PBXGroup;
+ children = (
+ 51B44C08A98D23EC80817C92 /* Pods-Tivu.debug.xcconfig */,
+ 1FF043395B53FE41BDD6504D /* Pods-Tivu.release.xcconfig */,
+ C7C5C641085E7E335C5DA1CD /* Pods-TivuTests.debug.xcconfig */,
+ 6838CE8BEF9376A02C451640 /* Pods-TivuTests.release.xcconfig */,
+ );
+ path = Pods;
+ sourceTree = "";
+ };
+ BACC644465788EDE9223331A /* Frameworks */ = {
+ isa = PBXGroup;
+ children = (
+ 3D9A5A102843F66200D2DD66 /* UIKit.framework */,
+ 3D9A5A0E2843F65D00D2DD66 /* VideoToolbox.framework */,
+ 3D9A5A0C2843F65900D2DD66 /* Security.framework */,
+ 3D9A5A0A2843F65500D2DD66 /* QuartzCore.framework */,
+ 3D9A5A082843F65000D2DD66 /* OpenGLES.framework */,
+ 3D9A5A062843F64C00D2DD66 /* libxml2.tbd */,
+ 3D9A5A042843F64700D2DD66 /* libiconv.tbd */,
+ 3D9A5A022843F64200D2DD66 /* libc++.tbd */,
+ 3D9A5A002843F63D00D2DD66 /* libbz2.tbd */,
+ 3D9A59FE2843F63800D2DD66 /* Foundation.framework */,
+ 3D9A59FC2843F63100D2DD66 /* CoreVideo.framework */,
+ 3D9A59FA2843F62D00D2DD66 /* CoreText.framework */,
+ 3D9A59F82843F62900D2DD66 /* CoreMedia.framework */,
+ 3D9A59F62843F62400D2DD66 /* CoreGraphics.framework */,
+ 3D9A59F42843F61F00D2DD66 /* CoreFoundation.framework */,
+ 3D9A59F22843F61B00D2DD66 /* CFNetwork.framework */,
+ 3D9A59F02843F61500D2DD66 /* AVFoundation.framework */,
+ 3D9A59EE2843F60F00D2DD66 /* AudioToolbox.framework */,
+ 35E9F7A68FC872FBDFE5263C /* Pods_Tivu.framework */,
+ 285CFA87B8E64C1361B3B11F /* Pods_TivuTests.framework */,
+ );
+ name = Frameworks;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 3DE9401A283EB6C10051FECD /* Tivu */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 3DE94044283EB6C20051FECD /* Build configuration list for PBXNativeTarget "Tivu" */;
+ buildPhases = (
+ D676643826696F183A80B4D3 /* [CP] Check Pods Manifest.lock */,
+ 3DE94017283EB6C10051FECD /* Sources */,
+ 3DE94018283EB6C10051FECD /* Frameworks */,
+ 3DE94019283EB6C10051FECD /* Resources */,
+ 13E8CD1C7B068CCFAA8AF46B /* [CP] Embed Pods Frameworks */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = Tivu;
+ productName = Tivu;
+ productReference = 3DE9401B283EB6C10051FECD /* Tivu.app */;
+ productType = "com.apple.product-type.application";
+ };
+ 3DE9402F283EB6C20051FECD /* TivuTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 3DE94047283EB6C20051FECD /* Build configuration list for PBXNativeTarget "TivuTests" */;
+ buildPhases = (
+ 2DDCE974B4CA777BEF91D0E3 /* [CP] Check Pods Manifest.lock */,
+ 3DE9402C283EB6C20051FECD /* Sources */,
+ 3DE9402D283EB6C20051FECD /* Frameworks */,
+ 3DE9402E283EB6C20051FECD /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 3DE94032283EB6C20051FECD /* PBXTargetDependency */,
+ );
+ name = TivuTests;
+ productName = TivuTests;
+ productReference = 3DE94030283EB6C20051FECD /* TivuTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 3DE94013283EB6C10051FECD /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 1330;
+ LastUpgradeCheck = 1330;
+ TargetAttributes = {
+ 3DE9401A283EB6C10051FECD = {
+ CreatedOnToolsVersion = 13.3.1;
+ LastSwiftMigration = 1330;
+ };
+ 3DE9402F283EB6C20051FECD = {
+ CreatedOnToolsVersion = 13.3.1;
+ TestTargetID = 3DE9401A283EB6C10051FECD;
+ };
+ };
+ };
+ buildConfigurationList = 3DE94016283EB6C10051FECD /* Build configuration list for PBXProject "Tivu" */;
+ compatibilityVersion = "Xcode 13.0";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 3DE94012283EB6C10051FECD;
+ productRefGroup = 3DE9401C283EB6C10051FECD /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 3DE9401A283EB6C10051FECD /* Tivu */,
+ 3DE9402F283EB6C20051FECD /* TivuTests */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 3DE94019283EB6C10051FECD /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 3DE94023283EB6C20051FECD /* Assets.xcassets in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 3DE9402E283EB6C20051FECD /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ 13E8CD1C7B068CCFAA8AF46B /* [CP] Embed Pods Frameworks */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Tivu/Pods-Tivu-frameworks-${CONFIGURATION}-input-files.xcfilelist",
+ );
+ name = "[CP] Embed Pods Frameworks";
+ outputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Tivu/Pods-Tivu-frameworks-${CONFIGURATION}-output-files.xcfilelist",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Tivu/Pods-Tivu-frameworks.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
+ 2DDCE974B4CA777BEF91D0E3 /* [CP] Check Pods Manifest.lock */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/Pods-TivuTests-checkManifestLockResult.txt",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+ showEnvVarsInLog = 0;
+ };
+ D676643826696F183A80B4D3 /* [CP] Check Pods Manifest.lock */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/Pods-Tivu-checkManifestLockResult.txt",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+ showEnvVarsInLog = 0;
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 3DE94017283EB6C10051FECD /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 3D753E95286A80F7009E7D3B /* NSManagedObjectContext+Merge.swift in Sources */,
+ 3D7E77E02841161F00CBAA23 /* Program+Persistence.swift in Sources */,
+ 3D0F07E328664FFB00CBA154 /* URL+BasicAuth.swift in Sources */,
+ 3D944D6028439478006E80CB /* CachedImage.swift in Sources */,
+ 3D4E239B286B42F700ABA96E /* FormField.swift in Sources */,
+ 3DE363E72843C1780004C325 /* SettingsView.swift in Sources */,
+ 3DA406372843FF4A00935D7A /* VideoView.swift in Sources */,
+ 3D4E23A3286B759D00ABA96E /* ChannelsListView+ViewModel.swift in Sources */,
+ 3DABDBCE2870760E0047C6DF /* Date+Within.swift in Sources */,
+ 3D2201B228685CAA00C8EA68 /* URL+PathComponents.swift in Sources */,
+ 3D4E239F286B4F9E00ABA96E /* ChannelView+ViewModel.swift in Sources */,
+ 3D4E2399286B429C00ABA96E /* Binding+Optional.swift in Sources */,
+ 3D7E77CE283F7EFA00CBAA23 /* Program.swift in Sources */,
+ 3D7E77CC283F7EDF00CBAA23 /* Channel.swift in Sources */,
+ 3DE9404E283EB7130051FECD /* XMLTVParser.swift in Sources */,
+ 3D0F07DF286608F900CBA154 /* Settings.swift in Sources */,
+ 3D4E2391286B2B1000ABA96E /* NowPlayingPill.swift in Sources */,
+ 3D944D1B284255AE006E80CB /* ProgramCard.swift in Sources */,
+ 3DA406392844022A00935D7A /* PlayerView.swift in Sources */,
+ 3D1EDD54284CD9ED004854E2 /* Program+Formatted.swift in Sources */,
+ 3D7E77DA283F83FD00CBAA23 /* Channel+Persistence.swift in Sources */,
+ 3D7E77DC283F890C00CBAA23 /* CacheManager.swift in Sources */,
+ 3D4E2397286B359100ABA96E /* NowPlayingPill+ViewModel.swift in Sources */,
+ 3DE9402B283EB6C20051FECD /* Tivu.xcdatamodeld in Sources */,
+ 3DE94021283EB6C10051FECD /* ContentView.swift in Sources */,
+ 3D944D5E28438107006E80CB /* ChannelView.swift in Sources */,
+ 3D4E2393286B309200ABA96E /* CachedProgram+FetchRequests.swift in Sources */,
+ 3D149DCF284BE0F300F6F228 /* DataManager.swift in Sources */,
+ 3D0F07E128664F7E00CBA154 /* TVHeadendServer.swift in Sources */,
+ 3DB7A5132870608C00C9427B /* Date+Formatted.swift in Sources */,
+ 3D4E23A1286B74AC00ABA96E /* Fetchable.swift in Sources */,
+ 3D0F07E52866540800CBA154 /* SettingsView+ViewModel.swift in Sources */,
+ 3D4E23A5286B75C200ABA96E /* Persistable.swift in Sources */,
+ 3D4E2395286B30AC00ABA96E /* CachedChannel+FetchRequests.swift in Sources */,
+ 3D7E77D2283F7F2B00CBAA23 /* Program+XMLTV.swift in Sources */,
+ 3DE9401F283EB6C10051FECD /* TivuApp.swift in Sources */,
+ 3D9A5A172843F95100D2DD66 /* Bridging-Header.h in Sources */,
+ 3D4E23A7286B7FBC00ABA96E /* DateInterval+Progress.swift in Sources */,
+ 3D944D1F284378AB006E80CB /* ChannelsListView.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 3DE9402C283EB6C20051FECD /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 3D2201B828685DB500C8EA68 /* TVHeadendServerTests.swift in Sources */,
+ 3D944D1828423E69006E80CB /* ProgramTests.swift in Sources */,
+ 3DE94050283EB7320051FECD /* XMLTVParserTests.swift in Sources */,
+ 3D7E77DE283F90A500CBAA23 /* CacheManagerTests.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ 3DE94032283EB6C20051FECD /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 3DE9401A283EB6C10051FECD /* Tivu */;
+ targetProxy = 3DE94031283EB6C20051FECD /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin XCBuildConfiguration section */
+ 3DE94042283EB6C20051FECD /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.4;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ 3DE94043283EB6C20051FECD /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.4;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 3DE94045283EB6C20051FECD /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 51B44C08A98D23EC80817C92 /* Pods-Tivu.debug.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = VUXB5VQ96M;
+ ENABLE_BITCODE = NO;
+ ENABLE_PREVIEWS = NO;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Tivu/Info.plist;
+ INFOPLIST_KEY_LSApplicationCategoryType = "";
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.janvitturi.Tivu;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_OBJC_BRIDGING_HEADER = "Tivu/Bridging-Header.h";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = 1;
+ };
+ name = Debug;
+ };
+ 3DE94046283EB6C20051FECD /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 1FF043395B53FE41BDD6504D /* Pods-Tivu.release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = VUXB5VQ96M;
+ ENABLE_BITCODE = NO;
+ ENABLE_PREVIEWS = NO;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Tivu/Info.plist;
+ INFOPLIST_KEY_LSApplicationCategoryType = "";
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.janvitturi.Tivu;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_OBJC_BRIDGING_HEADER = "Tivu/Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = 1;
+ };
+ name = Release;
+ };
+ 3DE94048283EB6C20051FECD /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = C7C5C641085E7E335C5DA1CD /* Pods-TivuTests.debug.xcconfig */;
+ buildSettings = {
+ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = VUXB5VQ96M;
+ GENERATE_INFOPLIST_FILE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.4;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.janvitturi.TivuTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Tivu.app/Tivu";
+ };
+ name = Debug;
+ };
+ 3DE94049283EB6C20051FECD /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 6838CE8BEF9376A02C451640 /* Pods-TivuTests.release.xcconfig */;
+ buildSettings = {
+ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = VUXB5VQ96M;
+ GENERATE_INFOPLIST_FILE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.4;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.janvitturi.TivuTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Tivu.app/Tivu";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 3DE94016283EB6C10051FECD /* Build configuration list for PBXProject "Tivu" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 3DE94042283EB6C20051FECD /* Debug */,
+ 3DE94043283EB6C20051FECD /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 3DE94044283EB6C20051FECD /* Build configuration list for PBXNativeTarget "Tivu" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 3DE94045283EB6C20051FECD /* Debug */,
+ 3DE94046283EB6C20051FECD /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 3DE94047283EB6C20051FECD /* Build configuration list for PBXNativeTarget "TivuTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 3DE94048283EB6C20051FECD /* Debug */,
+ 3DE94049283EB6C20051FECD /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+
+/* Begin XCVersionGroup section */
+ 3DE94029283EB6C20051FECD /* Tivu.xcdatamodeld */ = {
+ isa = XCVersionGroup;
+ children = (
+ 3DE9402A283EB6C20051FECD /* Tivu.xcdatamodel */,
+ );
+ currentVersion = 3DE9402A283EB6C20051FECD /* Tivu.xcdatamodel */;
+ path = Tivu.xcdatamodeld;
+ sourceTree = "";
+ versionGroupType = wrapper.xcdatamodel;
+ };
+/* End XCVersionGroup section */
+ };
+ rootObject = 3DE94013283EB6C10051FECD /* Project object */;
+}
diff --git a/Tivu.xcworkspace/contents.xcworkspacedata b/Tivu.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..25e154a
--- /dev/null
+++ b/Tivu.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
diff --git a/Tivu.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Tivu.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/Tivu.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/Tivu/Assets.xcassets/AccentColor.colorset/Contents.json b/Tivu/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..eb87897
--- /dev/null
+++ b/Tivu/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Tivu/Assets.xcassets/AppIcon.appiconset/Contents.json b/Tivu/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..ffdfad8
--- /dev/null
+++ b/Tivu/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,56 @@
+{
+ "images" : [
+ {
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "20x20"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "20x20"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "29x29"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "29x29"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "40x40"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "40x40"
+ },
+ {
+ "filename" : "Frame 1-2.png",
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "60x60"
+ },
+ {
+ "filename" : "Frame 1-1.png",
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "60x60"
+ },
+ {
+ "filename" : "Frame 1.png",
+ "idiom" : "ios-marketing",
+ "scale" : "1x",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Tivu/Assets.xcassets/AppIcon.appiconset/Frame 1-1.png b/Tivu/Assets.xcassets/AppIcon.appiconset/Frame 1-1.png
new file mode 100644
index 0000000..abbde51
Binary files /dev/null and b/Tivu/Assets.xcassets/AppIcon.appiconset/Frame 1-1.png differ
diff --git a/Tivu/Assets.xcassets/AppIcon.appiconset/Frame 1-2.png b/Tivu/Assets.xcassets/AppIcon.appiconset/Frame 1-2.png
new file mode 100644
index 0000000..fb09bd3
Binary files /dev/null and b/Tivu/Assets.xcassets/AppIcon.appiconset/Frame 1-2.png differ
diff --git a/Tivu/Assets.xcassets/AppIcon.appiconset/Frame 1.png b/Tivu/Assets.xcassets/AppIcon.appiconset/Frame 1.png
new file mode 100644
index 0000000..76d29ce
Binary files /dev/null and b/Tivu/Assets.xcassets/AppIcon.appiconset/Frame 1.png differ
diff --git a/Tivu/Assets.xcassets/Contents.json b/Tivu/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/Tivu/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Tivu/Bridging-Header.h b/Tivu/Bridging-Header.h
new file mode 100644
index 0000000..92353d6
--- /dev/null
+++ b/Tivu/Bridging-Header.h
@@ -0,0 +1 @@
+#import "MobileVLCKit/MobileVLCKit.h"
diff --git a/Tivu/Domains/TVHeadend/TVHeadendServer.swift b/Tivu/Domains/TVHeadend/TVHeadendServer.swift
new file mode 100644
index 0000000..e5e0a9d
--- /dev/null
+++ b/Tivu/Domains/TVHeadend/TVHeadendServer.swift
@@ -0,0 +1,15 @@
+struct TVHeadendServer: Equatable {
+ let url: URL
+
+ var xmltvURL: URL {
+ let url = url.appendingPathComponents(["xmltv", "channels"])
+ var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true)!
+ urlComponents.queryItems = urlComponents.queryItems ?? []
+ urlComponents.queryItems?.append(URLQueryItem(name: "lcn", value: "1"))
+ return urlComponents.url!
+ }
+
+ func streamURL(for channel: Channel) -> URL {
+ url.appendingPathComponents(["stream", "channel", channel.id])
+ }
+}
diff --git a/Tivu/Domains/XMLTV/Program+XMLTV.swift b/Tivu/Domains/XMLTV/Program+XMLTV.swift
new file mode 100644
index 0000000..44a4eba
--- /dev/null
+++ b/Tivu/Domains/XMLTV/Program+XMLTV.swift
@@ -0,0 +1,23 @@
+import Foundation
+
+extension Program.Number {
+ internal init?(xmltvString: String) {
+ let components = xmltvString
+ .replacingOccurrences(of: " ", with: "")
+ .components(separatedBy: ".")
+ guard components.count > 1 else { return nil }
+
+ self.init(
+ season: Self.parseComponent(components[0]),
+ episode: Self.parseComponent(components[1])
+ )
+ }
+
+ fileprivate static func parseComponent(_ xmltvComponent: String) -> Int? {
+ let value = xmltvComponent
+ .components(separatedBy: "/")
+ .first ?? ""
+ guard let value = Int(value) else { return nil }
+ return value + 1
+ }
+}
diff --git a/Tivu/Domains/XMLTV/XMLTVParser.swift b/Tivu/Domains/XMLTV/XMLTVParser.swift
new file mode 100644
index 0000000..52dbc7a
--- /dev/null
+++ b/Tivu/Domains/XMLTV/XMLTVParser.swift
@@ -0,0 +1,171 @@
+import Foundation
+
+protocol XMLTVParserDelegate: AnyObject {
+ func parser(_ parser: XMLTVParser, didFindChannel channel: Channel)
+ func parser(_ parser: XMLTVParser, didFindProgram program: Program)
+ func parser(_ parser: XMLTVParser, parseErrorOccurred parseError: Error)
+}
+
+class XMLTVParser: NSObject, XMLParserDelegate {
+ public var delegate: XMLTVParserDelegate?
+ private let parser: XMLParser
+
+ init?(contentsOf url: URL) {
+ guard let parser = XMLParser(contentsOf: url) else { return nil }
+ self.parser = parser
+ }
+
+ init(data: Data) {
+ self.parser = XMLParser(data: data)
+ }
+
+ init(stream: InputStream) {
+ self.parser = XMLParser(stream: stream)
+ }
+
+ func parse() {
+ parser.delegate = self
+ parser.parse()
+ parserDidEndDocument(parser)
+ }
+
+ private lazy var dateFormatter: DateFormatter = {
+ let dateFormatter = DateFormatter()
+ dateFormatter.dateFormat = "yyyyMMddHHmmss Z"
+ return dateFormatter
+ }()
+
+ private var invalidXMLTVError = NSError(
+ domain: "",
+ code: 0,
+ userInfo: [NSLocalizedDescriptionKey: "Invalid XMLTV"]
+ )
+
+ // MARK: State
+ private var foundRoot = false
+ private var currentPath = [String]()
+ private var currentContent = ""
+ private var lastContentPath = [String]()
+ private var currentChannel = [String: String]()
+ private var currentProgram = [String: String]()
+
+ // MARK: - Handle
+ internal func parser(_ parser: XMLParser,
+ didStartElement elementName: String,
+ namespaceURI: String?,
+ qualifiedName qName: String?,
+ attributes attributeDict: [String: String] = [:]) {
+ currentPath.append(elementName)
+
+ if !foundRoot && currentPath.first != "tv" {
+ didFindError(invalidXMLTVError)
+ }
+ foundRoot = true
+
+ switch currentPath {
+ case ["tv", "channel"]:
+ currentChannel = [:]
+ currentChannel["id"] = attributeDict["id"] ?? ""
+ case ["tv", "channel", "icon"]:
+ currentChannel["icon"] = attributeDict["src"] ?? ""
+ case ["tv", "programme"]:
+ currentProgram = [:]
+ currentProgram["start"] = attributeDict["start"] ?? ""
+ currentProgram["stop"] = attributeDict["stop"] ?? ""
+ currentProgram["channel"] = attributeDict["channel"] ?? ""
+ case ["tv", "programme", "icon"]:
+ currentProgram["icon"] = attributeDict["src"] ?? ""
+ default:
+ break
+ }
+ }
+
+ internal func parser(_ parser: XMLParser,
+ didEndElement elementName: String,
+ namespaceURI: String?,
+ qualifiedName qName: String?) {
+ switch currentPath {
+ case ["tv", "channel"]:
+ didEndChannel()
+ case ["tv", "channel", "display-name"]:
+ currentChannel["display-name"] = currentContent
+ case ["tv", "channel", "lcn"]:
+ currentChannel["lcn"] = currentContent
+ case ["tv", "programme"]:
+ didEndProgram()
+ case ["tv", "programme", "title"]:
+ currentProgram["title"] = currentContent
+ case ["tv", "programme", "sub-title"]:
+ currentProgram["sub-title"] = currentContent
+ case ["tv", "programme", "desc"]:
+ currentProgram["desc"] = currentContent
+ case ["tv", "programme", "episode-num"]:
+ currentProgram["episode-num"] = currentContent
+ default:
+ break
+ }
+
+ currentPath.removeLast()
+ }
+
+ internal func parser(_ parser: XMLParser,
+ foundCharacters string: String) {
+ if currentPath != lastContentPath {
+ lastContentPath = currentPath
+ currentContent = ""
+ }
+ currentContent.append(string)
+ }
+
+ func parser(_ parser: XMLParser,
+ parseErrorOccurred parseError: Error) {
+ didFindError(parseError)
+ }
+
+ func parserDidEndDocument(_ parser: XMLParser) {
+ if !foundRoot {
+ didFindError(invalidXMLTVError)
+ }
+ }
+
+ // MARK: - Publish
+ private func didEndChannel() {
+ defer { currentChannel.removeAll(keepingCapacity: true) }
+
+ guard let id = currentChannel["id"],
+ let name = currentChannel["display-name"]
+ else { return }
+
+ let channel = Channel(
+ id: id,
+ name: name,
+ number: Channel.Number(string: currentChannel["lcn"] ?? ""),
+ logoURL: URL(string: currentChannel["icon"] ?? "")
+ )
+ delegate?.parser(self, didFindChannel: channel)
+ }
+
+ private func didEndProgram() {
+ defer { currentProgram.removeAll(keepingCapacity: true) }
+
+ guard let startDate = dateFormatter.date(from: currentProgram["start"] ?? ""),
+ let endDate = dateFormatter.date(from: currentProgram["stop"] ?? ""),
+ let channel = currentProgram["channel"]
+ else { return }
+
+ let program = Program(
+ interval: DateInterval(start: startDate, end: endDate),
+ channelID: channel,
+ title: currentProgram["title"],
+ subtitle: currentProgram["sub-title"],
+ description: currentProgram["desc"],
+ imageURL: URL(string: currentProgram["icon"] ?? ""),
+ number: Program.Number(xmltvString: currentProgram["episode-num"] ?? "")
+ )
+ delegate?.parser(self, didFindProgram: program)
+ }
+
+ private func didFindError(_ parseError: Error) {
+ delegate?.parser(self, parseErrorOccurred: parseError)
+ }
+}
diff --git a/Tivu/Extensions/Binding+Optional.swift b/Tivu/Extensions/Binding+Optional.swift
new file mode 100644
index 0000000..6e2fde8
--- /dev/null
+++ b/Tivu/Extensions/Binding+Optional.swift
@@ -0,0 +1,8 @@
+import SwiftUI
+
+func ??(lhs: Binding>, rhs: T) -> Binding {
+ Binding(
+ get: { lhs.wrappedValue ?? rhs },
+ set: { lhs.wrappedValue = $0 }
+ )
+}
diff --git a/Tivu/Extensions/Date+Within.swift b/Tivu/Extensions/Date+Within.swift
new file mode 100644
index 0000000..b904bfc
--- /dev/null
+++ b/Tivu/Extensions/Date+Within.swift
@@ -0,0 +1,5 @@
+extension Date {
+ func withinLast(_ interval: TimeInterval) -> Bool {
+ DateInterval(start: self, duration: interval).contains(Date.now)
+ }
+}
diff --git a/Tivu/Extensions/DateInterval+Progress.swift b/Tivu/Extensions/DateInterval+Progress.swift
new file mode 100644
index 0000000..85b2f2c
--- /dev/null
+++ b/Tivu/Extensions/DateInterval+Progress.swift
@@ -0,0 +1,12 @@
+extension DateInterval {
+ var currentProgress: Double {
+ let start = self.start.timeIntervalSince1970
+ let now = Date.now.timeIntervalSince1970
+
+ let elapsed = now - start
+ let total = self.duration
+
+ guard elapsed > 0, total > 0 else { return 0 }
+ return min(elapsed / total, 1)
+ }
+}
diff --git a/Tivu/Extensions/NSManagedObjectContext+Merge.swift b/Tivu/Extensions/NSManagedObjectContext+Merge.swift
new file mode 100644
index 0000000..0b7e008
--- /dev/null
+++ b/Tivu/Extensions/NSManagedObjectContext+Merge.swift
@@ -0,0 +1,7 @@
+import CoreData
+
+extension NSManagedObjectContext {
+ func merge(changes: [AnyHashable: [NSManagedObjectID]]) {
+ NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [self])
+ }
+}
diff --git a/Tivu/Extensions/URL+BasicAuth.swift b/Tivu/Extensions/URL+BasicAuth.swift
new file mode 100644
index 0000000..029a5c1
--- /dev/null
+++ b/Tivu/Extensions/URL+BasicAuth.swift
@@ -0,0 +1,26 @@
+extension URL {
+ struct BasicAuth {
+ let user: String?
+ let password: String?
+ }
+
+ func removingBasicAuth() -> URL {
+ var urlComponents = URLComponents(url: self, resolvingAgainstBaseURL: true)!
+ urlComponents.basicAuth = BasicAuth(user: nil, password: nil)
+ return urlComponents.url!
+ }
+}
+
+extension URLComponents {
+ var basicAuth: URL.BasicAuth {
+ get {
+ URL.BasicAuth(user: self.user, password: self.password)
+ }
+ set {
+ let user = newValue.user ?? ""
+ let password = newValue.password ?? ""
+ self.user = user.isEmpty ? nil : user
+ self.password = password.isEmpty ? nil : password
+ }
+ }
+}
diff --git a/Tivu/Extensions/URL+PathComponents.swift b/Tivu/Extensions/URL+PathComponents.swift
new file mode 100644
index 0000000..629bd57
--- /dev/null
+++ b/Tivu/Extensions/URL+PathComponents.swift
@@ -0,0 +1,9 @@
+extension URL {
+ func appendingPathComponents(_ pathComponents: [String]) -> URL {
+ var url = self
+ for pathComponent in pathComponents {
+ url = url.appendingPathComponent(pathComponent)
+ }
+ return url
+ }
+}
diff --git a/Tivu/Info.plist b/Tivu/Info.plist
new file mode 100644
index 0000000..6a6654d
--- /dev/null
+++ b/Tivu/Info.plist
@@ -0,0 +1,11 @@
+
+
+
+
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+
+
+
diff --git a/Tivu/Models/Channel.swift b/Tivu/Models/Channel.swift
new file mode 100644
index 0000000..c425b84
--- /dev/null
+++ b/Tivu/Models/Channel.swift
@@ -0,0 +1,46 @@
+struct Channel {
+ struct Number {
+ let major: UInt
+ let minor: UInt?
+
+ init(_ major: UInt, _ minor: UInt? = nil) {
+ self.major = major
+ self.minor = minor
+ }
+ }
+
+ let id: String
+ let name: String
+ let number: Number?
+ let logoURL: URL?
+}
+
+extension Channel: Equatable {
+}
+
+extension Channel.Number: Equatable {
+}
+
+extension Channel.Number: CustomStringConvertible {
+ var description: String {
+ if let minor = minor {
+ return "\(major).\(minor)"
+ }
+ return "\(major)"
+ }
+
+ init?(string: String) {
+ let components = string.components(separatedBy: ".")
+ switch components.count {
+ case 1:
+ guard let major = UInt(components[0]) else { return nil }
+ self.init(major)
+ case 2:
+ guard let major = UInt(components[0]),
+ let minor = UInt(components[1]) else { return nil }
+ self.init(major, minor)
+ default:
+ return nil
+ }
+ }
+}
diff --git a/Tivu/Models/Program.swift b/Tivu/Models/Program.swift
new file mode 100644
index 0000000..35586e6
--- /dev/null
+++ b/Tivu/Models/Program.swift
@@ -0,0 +1,64 @@
+struct Program {
+ struct Number {
+ let season: Int?
+ let episode: Int?
+
+ init?(season: Int? = nil, episode: Int? = nil) {
+ if season == nil && episode == nil {
+ return nil
+ }
+ self.season = season
+ self.episode = episode
+ }
+ }
+
+ let interval: DateInterval
+ let channelID: String
+ let title: String?
+ let subtitle: String?
+ let description: String?
+ let imageURL: URL?
+ let number: Number?
+}
+
+extension Program: Equatable {
+}
+
+extension Program.Number: Equatable {
+}
+
+extension Program.Number: CustomStringConvertible {
+ var description: String {
+ var result = ""
+ if let season = season {
+ result += "S\(season)"
+ }
+ if let episode = episode {
+ if !result.isEmpty {
+ result += " " // Thin Space U+2009
+ }
+ result += "E\(episode)"
+ }
+ return result
+ }
+
+ init?(string: String) {
+ let regex = try! NSRegularExpression(pattern: #"S(?\d+)\s*E(?\d+)"#, options: [.caseInsensitive])
+ let matches = regex.matches(in: string, range: NSRange(string.startIndex.. 0 else { return Date.distantPast }
+ return Date(timeIntervalSince1970: TimeInterval(timestamp))
+ }
+ set {
+ if newValue != lastSync {
+ userDefaults.set(Int(newValue.timeIntervalSince1970), forKey: "lastSync")
+ objectWillChange.send()
+ }
+ }
+ }
+}
diff --git a/Tivu/Persistence/CacheManager.swift b/Tivu/Persistence/CacheManager.swift
new file mode 100644
index 0000000..05afca2
--- /dev/null
+++ b/Tivu/Persistence/CacheManager.swift
@@ -0,0 +1,66 @@
+import CoreData
+
+class CacheManager {
+ static let shared = CacheManager()
+
+ let container: NSPersistentContainer
+
+ var viewContext: NSManagedObjectContext {
+ container.viewContext
+ }
+
+ init(inMemory: Bool = false) {
+ container = NSPersistentContainer(name: "Tivu")
+ if inMemory {
+ container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
+ }
+ container.loadPersistentStores(completionHandler: { (storeDescription, error) in
+ if let error = error as NSError? {
+ fatalError("Unresolved error \(error), \(error.userInfo)")
+ }
+ })
+ container.viewContext.automaticallyMergesChangesFromParent = true
+ }
+
+ func refresh(fill: @escaping (_ context: NSManagedObjectContext) throws -> Void) async throws {
+ try await container.performBackgroundTask { context in
+ context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
+
+ try self.markAllExistingEntitiesAsStale(context: context)
+ try fill(context)
+ try context.save()
+
+ let changes = try self.deleteAllStaleEntities(context: context)
+ try context.save()
+
+ /*
+ Objects deleted via NSBatchDeleteRequest have not triggered any
+ notifications because changes occurred directly in the persistent
+ store, not from within a NSManagedObjectContext.
+ Therefore, the viewContext should be notified about those manually.
+ */
+ self.container.viewContext.merge(changes: changes)
+ }
+ }
+
+ private func markAllExistingEntitiesAsStale(context: NSManagedObjectContext) throws {
+ let updateRequest = NSBatchUpdateRequest(entity: CachedEntity.entity())
+ updateRequest.propertiesToUpdate = [#keyPath(CachedEntity.stale): true]
+
+ updateRequest.resultType = .updatedObjectIDsResultType
+ guard let result = try context.execute(updateRequest) as? NSBatchUpdateResult,
+ let objectIDs = result.result as? [NSManagedObjectID] else { return }
+ context.merge(changes: [NSUpdatedObjectIDsKey: objectIDs])
+ }
+
+ private func deleteAllStaleEntities(context: NSManagedObjectContext) throws -> [String: [NSManagedObjectID]] {
+ let fetchRequest: NSFetchRequest = CachedEntity.fetchRequest()
+ fetchRequest.predicate = NSPredicate(format: "%K == true", #keyPath(CachedEntity.stale))
+ let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
+
+ deleteRequest.resultType = .resultTypeObjectIDs
+ guard let result = try context.execute(deleteRequest) as? NSBatchDeleteResult,
+ let objectIDs = result.result as? [NSManagedObjectID] else { return [:] }
+ return [NSDeletedObjectsKey: objectIDs]
+ }
+}
diff --git a/Tivu/Persistence/DataManager.swift b/Tivu/Persistence/DataManager.swift
new file mode 100644
index 0000000..f0ae27a
--- /dev/null
+++ b/Tivu/Persistence/DataManager.swift
@@ -0,0 +1,69 @@
+import CoreData
+
+actor DataManager {
+ static let shared = DataManager(
+ cacheManager: CacheManager.shared,
+ settings: Settings.shared
+ )
+
+ private static let refreshInterval = 15 * minute
+
+ private var cacheManager: CacheManager
+ private var settings: Settings
+
+ init(
+ cacheManager: CacheManager,
+ settings: Settings
+ ) {
+ self.cacheManager = cacheManager
+ self.settings = settings
+ }
+
+ func refreshIfNeeded() async throws {
+ if !settings.lastSync.withinLast(Self.refreshInterval) {
+ try await refresh()
+ }
+ }
+
+ func refresh() async throws {
+ guard let server = settings.server else { return }
+ try await refresh(withDataFrom: server.xmltvURL)
+ settings.lastSync = Date.now
+ }
+
+ func refresh(withDataFrom url: URL) async throws {
+ let (data, _) = try await URLSession.shared.data(from: url)
+ try await cacheManager.refresh { context in
+ let parser = XMLTVParser(data: data)
+ let delegate = XMLTVCacher(context: context)
+ parser.delegate = delegate
+ parser.parse()
+ if let parseError = delegate.parseError {
+ throw parseError
+ }
+ }
+ }
+}
+
+fileprivate class XMLTVCacher: XMLTVParserDelegate {
+ let context: NSManagedObjectContext
+ var parseError: Error?
+
+ init(context: NSManagedObjectContext) {
+ self.context = context
+ }
+
+ func parser(_ parser: XMLTVParser, didFindChannel channel: Channel) {
+ channel.persist(context: context)
+ }
+
+ func parser(_ parser: XMLTVParser, didFindProgram program: Program) {
+ program.persist(context: context)
+ }
+
+ func parser(_ parser: XMLTVParser, parseErrorOccurred parseError: Error) {
+ self.parseError = parseError
+ }
+}
+
+fileprivate let minute = 60.0
diff --git a/Tivu/Persistence/Extensions/CachedChannel+FetchRequests.swift b/Tivu/Persistence/Extensions/CachedChannel+FetchRequests.swift
new file mode 100644
index 0000000..4ad195d
--- /dev/null
+++ b/Tivu/Persistence/Extensions/CachedChannel+FetchRequests.swift
@@ -0,0 +1,18 @@
+import CoreData
+
+extension CachedChannel {
+ class public func fetchRequest(sorted: Bool) -> NSFetchRequest {
+ let request: NSFetchRequest = CachedChannel.fetchRequest()
+ request.sortDescriptors = []
+ if sorted {
+ request.sortDescriptors = [
+ NSSortDescriptor(
+ key: #keyPath(CachedChannel.number),
+ ascending: true,
+ selector: #selector(NSString.localizedStandardCompare(_:))
+ )
+ ]
+ }
+ return request
+ }
+}
diff --git a/Tivu/Persistence/Extensions/CachedProgram+FetchRequests.swift b/Tivu/Persistence/Extensions/CachedProgram+FetchRequests.swift
new file mode 100644
index 0000000..dbc1830
--- /dev/null
+++ b/Tivu/Persistence/Extensions/CachedProgram+FetchRequests.swift
@@ -0,0 +1,31 @@
+import CoreData
+
+extension CachedProgram {
+ class func fetchRequest(channel: Channel, interval: DateInterval) -> NSFetchRequest {
+ let request: NSFetchRequest = CachedProgram.fetchRequest()
+ request.predicate = NSPredicate(
+ format: "%K == %@ AND %K >= %@ AND %K < %@",
+ #keyPath(CachedProgram.channelID), channel.id,
+ #keyPath(CachedProgram.endTime), interval.start as NSDate,
+ #keyPath(CachedProgram.startTime), interval.end as NSDate
+ )
+ request.sortDescriptors = [
+ NSSortDescriptor(key: #keyPath(CachedProgram.startTime), ascending: true)
+ ]
+ return request
+ }
+
+ class func fetchRequest(channel: Channel, endingBefore: Date, limit: Int) -> NSFetchRequest {
+ let request: NSFetchRequest = CachedProgram.fetchRequest()
+ request.predicate = NSPredicate(
+ format: "%K == %@ AND %K > %@",
+ #keyPath(CachedProgram.channelID), channel.id,
+ #keyPath(CachedProgram.endTime), endingBefore as NSDate
+ )
+ request.sortDescriptors = [
+ NSSortDescriptor(key: #keyPath(CachedProgram.startTime), ascending: true)
+ ]
+ request.fetchLimit = limit
+ return request
+ }
+}
diff --git a/Tivu/Persistence/Extensions/Channel+Persistence.swift b/Tivu/Persistence/Extensions/Channel+Persistence.swift
new file mode 100644
index 0000000..16edb0d
--- /dev/null
+++ b/Tivu/Persistence/Extensions/Channel+Persistence.swift
@@ -0,0 +1,22 @@
+import CoreData
+
+extension Channel: Fetchable {
+ init(result: CachedChannel) {
+ self.init(
+ id: result.id!,
+ name: result.name!,
+ number: Channel.Number(string: result.number ?? ""),
+ logoURL: result.logoURL
+ )
+ }
+}
+
+extension Channel: Persistable {
+ func persist(context: NSManagedObjectContext) {
+ let managedObject = CachedChannel(context: context)
+ managedObject.id = self.id
+ managedObject.name = self.name
+ managedObject.number = self.number?.description
+ managedObject.logoURL = self.logoURL
+ }
+}
diff --git a/Tivu/Persistence/Extensions/Program+Persistence.swift b/Tivu/Persistence/Extensions/Program+Persistence.swift
new file mode 100644
index 0000000..17f3494
--- /dev/null
+++ b/Tivu/Persistence/Extensions/Program+Persistence.swift
@@ -0,0 +1,31 @@
+import CoreData
+import Foundation
+
+extension Program: Fetchable {
+ init(result: CachedProgram) {
+ self.init(
+ interval: DateInterval(start: result.startTime!, end: result.endTime!),
+ channelID: result.channelID!,
+ title: result.title,
+ subtitle: result.subtitle,
+ description: result.desc,
+ imageURL: result.imageURL,
+ number: Program.Number(string: result.number ?? "")
+ )
+ }
+}
+
+extension Program: Persistable {
+ func persist(context: NSManagedObjectContext) {
+ let managedObject = CachedProgram(context: context)
+ managedObject.channelID = self.channelID
+ managedObject.desc = self.description
+ managedObject.endTime = self.interval.end
+ managedObject.id = "\(self.channelID)+\(self.interval.start.timeIntervalSince1970)"
+ managedObject.imageURL = self.imageURL
+ managedObject.number = self.number?.description
+ managedObject.startTime = self.interval.start
+ managedObject.subtitle = self.subtitle
+ managedObject.title = self.title
+ }
+}
diff --git a/Tivu/Persistence/Fetchable.swift b/Tivu/Persistence/Fetchable.swift
new file mode 100644
index 0000000..b5bdc0d
--- /dev/null
+++ b/Tivu/Persistence/Fetchable.swift
@@ -0,0 +1,24 @@
+import CoreData
+
+protocol Fetchable {
+ associatedtype R: NSFetchRequestResult
+
+ init(result: R)
+}
+
+struct MappedFetchResults: Equatable, RandomAccessCollection {
+ private let results: NSArray
+
+ init(_ results: NSArray = NSArray()) {
+ self.results = results
+ }
+
+ var count: Int { results.count }
+ var startIndex: Int { 0 }
+ var endIndex: Int { count }
+
+ subscript(position: Int) -> F {
+ let object = results.object(at: position) as! F.R
+ return F(result: object)
+ }
+}
diff --git a/Tivu/Persistence/Persistable.swift b/Tivu/Persistence/Persistable.swift
new file mode 100644
index 0000000..e1c998a
--- /dev/null
+++ b/Tivu/Persistence/Persistable.swift
@@ -0,0 +1,5 @@
+import CoreData
+
+protocol Persistable {
+ func persist(context: NSManagedObjectContext)
+}
diff --git a/Tivu/Persistence/Tivu.xcdatamodeld/.xccurrentversion b/Tivu/Persistence/Tivu.xcdatamodeld/.xccurrentversion
new file mode 100644
index 0000000..a6dc531
--- /dev/null
+++ b/Tivu/Persistence/Tivu.xcdatamodeld/.xccurrentversion
@@ -0,0 +1,8 @@
+
+
+
+
+ _XCCurrentVersionName
+ Tivu.xcdatamodel
+
+
diff --git a/Tivu/Persistence/Tivu.xcdatamodeld/Tivu.xcdatamodel/contents b/Tivu/Persistence/Tivu.xcdatamodeld/Tivu.xcdatamodel/contents
new file mode 100644
index 0000000..a6b6550
--- /dev/null
+++ b/Tivu/Persistence/Tivu.xcdatamodeld/Tivu.xcdatamodel/contents
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Tivu/TivuApp.swift b/Tivu/TivuApp.swift
new file mode 100644
index 0000000..2199864
--- /dev/null
+++ b/Tivu/TivuApp.swift
@@ -0,0 +1,29 @@
+import SwiftUI
+
+@main
+struct TivuApp: App {
+ private var imagesURLCache = URLCache(
+ memoryCapacity: 128 * mb,
+ diskCapacity: 256 * mb,
+ directory: URL(string: "images")
+ )
+ private let dataManager = DataManager.shared
+
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ .environment(\.imagesURLCache, imagesURLCache)
+ .task {
+ try? await dataManager.refresh()
+ Timer.scheduledTimer(withTimeInterval: 5 * minute, repeats: true) { timer in
+ Task {
+ try? await dataManager.refreshIfNeeded()
+ }
+ }
+ }
+ }
+ }
+}
+
+fileprivate let mb = 1024 * 1024
+fileprivate let minute = 60.0
diff --git a/Tivu/Views/ContentView.swift b/Tivu/Views/ContentView.swift
new file mode 100644
index 0000000..028a6e5
--- /dev/null
+++ b/Tivu/Views/ContentView.swift
@@ -0,0 +1,30 @@
+import SwiftUI
+import CoreData
+
+struct ContentView: View {
+ @State private var showingPreferences = false
+
+ var body: some View {
+ NavigationView {
+ ChannelsListView()
+ .navigationTitle("Tivu")
+ .toolbar {
+ Button {
+ showingPreferences.toggle()
+ } label: {
+ Label("Settings", systemImage: "gear")
+ .labelStyle(.iconOnly)
+ }
+ }
+ }
+ .navigationViewStyle(.stack)
+ .sheet(isPresented: $showingPreferences) {
+ SettingsView()
+ }
+ .onAppear {
+ if Settings.shared.server == nil {
+ showingPreferences = true
+ }
+ }
+ }
+}
diff --git a/Tivu/Views/Extensions/Date+Formatted.swift b/Tivu/Views/Extensions/Date+Formatted.swift
new file mode 100644
index 0000000..cdef326
--- /dev/null
+++ b/Tivu/Views/Extensions/Date+Formatted.swift
@@ -0,0 +1,9 @@
+extension Date {
+ var formattedTimeLeft: String {
+ let left = self.timeIntervalSince1970 - Date.now.timeIntervalSince1970
+ let formatter = DateComponentsFormatter()
+ formatter.unitsStyle = .abbreviated
+ formatter.allowedUnits = [.hour, .minute]
+ return formatter.string(from: left + 60)!
+ }
+}
diff --git a/Tivu/Views/Extensions/Program+Formatted.swift b/Tivu/Views/Extensions/Program+Formatted.swift
new file mode 100644
index 0000000..64d2551
--- /dev/null
+++ b/Tivu/Views/Extensions/Program+Formatted.swift
@@ -0,0 +1,26 @@
+extension Program {
+ var formattedSubtitle: String? {
+ var result: String?
+ if let subtitle = subtitle {
+ result = subtitle
+ }
+ if let number = number?.description {
+ if result == nil {
+ result = number
+ } else {
+ result! += " (\(number))"
+ }
+ }
+ return result
+ }
+
+ var formattedInterval: String {
+ let formatter = DateComponentsFormatter()
+ formatter.unitsStyle = .abbreviated
+ formatter.allowedUnits = [.hour, .minute]
+
+ let start = interval.start.formatted(date: .omitted, time: .shortened)
+ let duration = formatter.string(from: interval.duration)!
+ return "\(start) • \(duration)"
+ }
+}
diff --git a/Tivu/Views/Generic/CachedImage.swift b/Tivu/Views/Generic/CachedImage.swift
new file mode 100644
index 0000000..953da58
--- /dev/null
+++ b/Tivu/Views/Generic/CachedImage.swift
@@ -0,0 +1,49 @@
+import SwiftUI
+
+struct CachedImage: View {
+ let url: URL?
+ @ViewBuilder var content: (Image) -> I
+ @ViewBuilder var placeholder: P
+
+ @Environment(\.imagesURLCache) private var urlCache
+ @State private var image: Image?
+
+ var body: some View {
+ if let image = image {
+ content(image)
+ } else {
+ placeholder
+ .onAppear {
+ loadImage()
+ }
+ }
+ }
+
+ func loadImage() {
+ Task.detached(priority: .background) {
+ guard let url = url else { return }
+
+ let session = URLSession(configuration: .ephemeral)
+ session.configuration.urlCache = urlCache
+ let request = URLRequest(url: url, cachePolicy: .returnCacheDataElseLoad)
+ guard let (data, _) = try? await session.data(for: request),
+ let uiImage = UIImage(data: data) else { return }
+
+ let image = Image(uiImage: uiImage)
+ await MainActor.run {
+ self.image = image
+ }
+ }
+ }
+}
+
+fileprivate struct ImagesURLCacheKey: EnvironmentKey {
+ static let defaultValue = URLCache.shared
+}
+
+extension EnvironmentValues {
+ var imagesURLCache: URLCache {
+ get { self[ImagesURLCacheKey.self] }
+ set { self[ImagesURLCacheKey.self] = newValue }
+ }
+}
diff --git a/Tivu/Views/Navigation/ChannelView+ViewModel.swift b/Tivu/Views/Navigation/ChannelView+ViewModel.swift
new file mode 100644
index 0000000..5791fe2
--- /dev/null
+++ b/Tivu/Views/Navigation/ChannelView+ViewModel.swift
@@ -0,0 +1,79 @@
+import CoreData
+import Combine
+
+extension ChannelView {
+ @MainActor class ViewModel: NSObject, ObservableObject {
+ let channel: Channel
+
+ @Published private(set) var programs = MappedFetchResults()
+ @Published private(set) var hideSchedule: Bool
+
+ private let controller: NSFetchedResultsController
+ private let settings: Settings
+ private var settingsChangedSink: AnyCancellable?
+
+ init(
+ channel: Channel,
+ context: NSManagedObjectContext = CacheManager.shared.viewContext,
+ settings: Settings = Settings.shared
+ ) {
+ self.channel = channel
+ self.controller = NSFetchedResultsController(
+ fetchRequest: Self.fetchRequest(channel: channel),
+ managedObjectContext: context,
+ sectionNameKeyPath: nil, cacheName: nil
+ )
+ self.settings = settings
+ self.hideSchedule = settings.hideSchedule
+ super.init()
+
+ settingsChangedSink = settings.objectWillChange.sink {
+ Task {
+ await MainActor.run {
+ self.hideSchedule = settings.hideSchedule
+ }
+ }
+ }
+ controller.delegate = self
+ fetchPrograms()
+ }
+
+ var streamURL: URL? {
+ settings.server?.streamURL(for: channel)
+ }
+
+ func fetchPrograms() {
+ controller.fetchRequest.predicate = Self.fetchRequest(channel: self.channel).predicate
+ Task.detached(priority: .background) {
+ try? self.controller.performFetch()
+ await self.reloadPrograms()
+ }
+ }
+
+ @MainActor private func reloadPrograms() async {
+ let results = (controller.fetchedObjects as NSArray?) ?? NSArray()
+ self.programs = MappedFetchResults(results)
+ }
+
+ private class func fetchRequest(channel: Channel) -> NSFetchRequest {
+ CachedProgram.fetchRequest(
+ channel: channel,
+ interval: DateInterval(start: Date.now, duration: 1 * day)
+ )
+ }
+ }
+}
+
+extension ChannelView.ViewModel: NSFetchedResultsControllerDelegate {
+ func controllerDidChangeContent(_ controller: NSFetchedResultsController) {
+ Task {
+ await self.reloadPrograms()
+ }
+ }
+
+ func controllerWillChangeContent(_ controller: NSFetchedResultsController) {
+ objectWillChange.send()
+ }
+}
+
+fileprivate let day: TimeInterval = 60 * 60 * 24
diff --git a/Tivu/Views/Navigation/ChannelView.swift b/Tivu/Views/Navigation/ChannelView.swift
new file mode 100644
index 0000000..3bc746e
--- /dev/null
+++ b/Tivu/Views/Navigation/ChannelView.swift
@@ -0,0 +1,99 @@
+import SwiftUI
+import CoreData
+
+struct ChannelView: View {
+ let channel: Channel
+
+ @StateObject private var viewModel: ViewModel
+
+ @ScaledMetric private var logoSize: CGFloat = 32
+ @State private var updateTimer: Timer?
+ @State private var showingPlayer = false
+
+ init(channel: Channel) {
+ self.channel = channel
+ self._viewModel = StateObject(wrappedValue: ViewModel(channel: channel))
+ }
+
+ var body: some View {
+ VStack {
+ HStack {
+ CachedImage(
+ url: channel.logoURL,
+ content: { image in
+ image
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ },
+ placeholder: {
+ Text(channel.number?.description ?? channel.name)
+ .lineLimit(1)
+ .font(.caption.monospaced().bold())
+ .foregroundColor(.secondary)
+ .minimumScaleFactor(0.01)
+ }
+ )
+ .frame(width: logoSize, height: logoSize)
+ .background(Color.secondary.opacity(0.2))
+ .cornerRadius(4)
+
+ VStack(alignment: .leading) {
+ Text(channel.name)
+ .font(.headline)
+
+ if let number = channel.number {
+ Text("Channel \(number.description)")
+ .font(.caption.bold())
+ .foregroundColor(.secondary)
+ }
+ }
+
+ Spacer()
+
+ Button {
+ showingPlayer.toggle()
+ } label: {
+ Label("Play", systemImage: "play")
+ .symbolVariant(.fill)
+ .labelStyle(.iconOnly)
+ }
+ .fullScreenCover(isPresented: $showingPlayer) {
+ if let streamURL = viewModel.streamURL {
+ PlayerView(channel: channel, streamURL: streamURL)
+ }
+ }
+ }
+ .padding(.horizontal)
+
+ if !viewModel.hideSchedule {
+ Group {
+ if viewModel.programs.isEmpty {
+ Text("No program schedule available")
+ .foregroundColor(.secondary)
+ .padding()
+ } else {
+ ScrollView(.horizontal, showsIndicators: false) {
+ LazyHStack(spacing: 16) {
+ ForEach(viewModel.programs, id: \.interval.start) { program in
+ ProgramCard(program: program)
+ }
+ }
+ .padding()
+ }
+ }
+ }
+ .padding(.top, -8)
+ }
+ }
+ .animation(.default, value: viewModel.programs)
+ .onAppear {
+ viewModel.fetchPrograms()
+ updateTimer = Timer.scheduledTimer(withTimeInterval: 15, repeats: true) { timer in
+ viewModel.fetchPrograms()
+ }
+ }
+ .onDisappear {
+ updateTimer?.invalidate()
+ }
+ }
+}
diff --git a/Tivu/Views/Navigation/ChannelsListView+ViewModel.swift b/Tivu/Views/Navigation/ChannelsListView+ViewModel.swift
new file mode 100644
index 0000000..2f99401
--- /dev/null
+++ b/Tivu/Views/Navigation/ChannelsListView+ViewModel.swift
@@ -0,0 +1,35 @@
+import CoreData
+
+extension ChannelsListView {
+ @MainActor class ViewModel: NSObject, ObservableObject {
+ @Published private(set) var channels = MappedFetchResults()
+
+ private let controller: NSFetchedResultsController
+
+ init(context: NSManagedObjectContext = CacheManager.shared.viewContext) {
+ self.controller = NSFetchedResultsController(
+ fetchRequest: CachedChannel.fetchRequest(sorted: true),
+ managedObjectContext: context,
+ sectionNameKeyPath: nil, cacheName: nil
+ )
+ super.init()
+ controller.delegate = self
+ }
+
+ func fetchChannels() {
+ try? controller.performFetch()
+ controllerDidChangeContent(controller as! NSFetchedResultsController)
+ }
+ }
+}
+
+extension ChannelsListView.ViewModel: NSFetchedResultsControllerDelegate {
+ func controllerDidChangeContent(_ controller: NSFetchedResultsController) {
+ let results = (controller.fetchedObjects as NSArray?) ?? NSArray()
+ self.channels = MappedFetchResults(results)
+ }
+
+ func controllerWillChangeContent(_ controller: NSFetchedResultsController) {
+ objectWillChange.send()
+ }
+}
diff --git a/Tivu/Views/Navigation/ChannelsListView.swift b/Tivu/Views/Navigation/ChannelsListView.swift
new file mode 100644
index 0000000..41a989b
--- /dev/null
+++ b/Tivu/Views/Navigation/ChannelsListView.swift
@@ -0,0 +1,28 @@
+import SwiftUI
+
+struct ChannelsListView: View {
+ @StateObject private var viewModel: ViewModel
+
+ init() {
+ self._viewModel = StateObject(wrappedValue: ViewModel())
+ }
+
+ var body: some View {
+ ScrollView {
+ LazyVStack(alignment: .leading) {
+ ForEach(viewModel.channels, id: \.id) { channel in
+ ChannelView(channel: channel)
+
+ Divider()
+ .padding(.leading)
+ }
+ }
+ }
+ .animation(.default, value: viewModel.channels)
+ .onAppear {
+ withAnimation(.none) {
+ viewModel.fetchChannels()
+ }
+ }
+ }
+}
diff --git a/Tivu/Views/Navigation/ProgramCard.swift b/Tivu/Views/Navigation/ProgramCard.swift
new file mode 100644
index 0000000..a65aeda
--- /dev/null
+++ b/Tivu/Views/Navigation/ProgramCard.swift
@@ -0,0 +1,86 @@
+import SwiftUI
+
+struct ProgramCard: View {
+ let program: Program
+
+ @State private var progress = 0.0
+ @State private var updateTimer: Timer?
+
+ @ScaledMetric private var width = 280
+ @ScaledMetric private var height = 175
+
+ var body: some View {
+ ZStack {
+ CachedImage(
+ url: program.imageURL,
+ content: { image in
+ image
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ .frame(width: width, height: height)
+ },
+ placeholder: {
+ Text("")
+ }
+ )
+
+ LinearGradient(
+ gradient: Gradient(colors: [.clear, .black.opacity(0.8)]),
+ startPoint: .top,
+ endPoint: .bottom
+ )
+
+ VStack(alignment: .leading) {
+ Spacer()
+
+ VStack(alignment: .leading, spacing: 2) {
+ if let title = program.title {
+ Text(title)
+ .foregroundColor(.white)
+ .bold()
+ .truncationMode(.middle)
+ .shadow(radius: 1)
+ }
+
+ if let subtitle = program.formattedSubtitle {
+ Text(subtitle)
+ .foregroundColor(.white)
+ .font(.footnote)
+ .truncationMode(.middle)
+ .shadow(radius: 1)
+ }
+
+ Text(program.formattedInterval)
+ .foregroundColor(.gray)
+ .font(.caption.monospacedDigit())
+ .bold()
+ .shadow(radius: 1)
+ }
+ .padding(.horizontal, 8)
+
+ ZStack(alignment: .leading) {
+ Rectangle()
+ .foregroundColor(progress == 0 ? .clear : .gray)
+ Rectangle()
+ .foregroundColor(.white)
+ .scaleEffect(x: progress, y: 1, anchor: .leading)
+ }
+ .frame(height: 3)
+ .opacity(0.5)
+ }
+ }
+ .background(.gray)
+ .cornerRadius(8)
+ .frame(width: width, height: height)
+ .shadow(radius: 3)
+ .onAppear {
+ progress = program.interval.currentProgress
+ updateTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in
+ progress = program.interval.currentProgress
+ }
+ }
+ .onDisappear {
+ updateTimer?.invalidate()
+ }
+ }
+}
diff --git a/Tivu/Views/Player/NowPlayingPill+ViewModel.swift b/Tivu/Views/Player/NowPlayingPill+ViewModel.swift
new file mode 100644
index 0000000..07a928f
--- /dev/null
+++ b/Tivu/Views/Player/NowPlayingPill+ViewModel.swift
@@ -0,0 +1,44 @@
+import CoreData
+
+extension NowPlayingPill {
+ @MainActor class ViewModel: ObservableObject {
+ @Published private(set) var currentProgram: Program?
+ @Published private(set) var upcomingProgram: Program?
+
+ private let channel: Channel
+ private let context: NSManagedObjectContext
+
+ init(
+ channel: Channel,
+ context: NSManagedObjectContext = CacheManager.shared.viewContext
+ ) {
+ self.channel = channel
+ self.context = context
+ updatePrograms()
+ }
+
+ func updatePrograms() {
+ guard let results = try? context.fetch(fetchRequest) else { return }
+
+ if results.count > 0 {
+ currentProgram = Program(result: results[0])
+ if results.count > 1 {
+ upcomingProgram = Program(result: results[1])
+ } else {
+ upcomingProgram = nil
+ }
+ return
+ }
+ currentProgram = nil
+ upcomingProgram = nil
+ }
+
+ private var fetchRequest: NSFetchRequest {
+ CachedProgram.fetchRequest(
+ channel: channel,
+ endingBefore: Date.now,
+ limit: 2
+ )
+ }
+ }
+}
diff --git a/Tivu/Views/Player/NowPlayingPill.swift b/Tivu/Views/Player/NowPlayingPill.swift
new file mode 100644
index 0000000..1d1d15b
--- /dev/null
+++ b/Tivu/Views/Player/NowPlayingPill.swift
@@ -0,0 +1,107 @@
+import SwiftUI
+
+struct NowPlayingPill: View {
+ @StateObject var viewModel: ViewModel
+
+ @State private var progress = 0.0
+ @State private var updateTimer: Timer?
+ @ScaledMetric private var programImageWidth = 125
+ @ScaledMetric private var programImageHeight = 78
+
+ init(channel: Channel) {
+ self._viewModel = StateObject(wrappedValue: ViewModel(channel: channel))
+ }
+
+ var body: some View {
+ Group {
+ if let program = viewModel.currentProgram {
+ VStack(spacing: 0) {
+ HStack(alignment: .center) {
+ CachedImage(
+ url: program.imageURL,
+ content: { image in
+ image
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ .frame(width: programImageWidth, height: programImageHeight)
+ .clipped()
+ },
+ placeholder: {
+ Rectangle()
+ .frame(width: 0, height: 0)
+ .padding(.trailing, 8)
+ }
+ )
+
+ VStack(alignment: .leading, spacing: 2) {
+ if let title = program.title {
+ Text(title)
+ .foregroundColor(.white)
+ .bold()
+ .lineLimit(1)
+ .truncationMode(.middle)
+ }
+
+ if let subtitle = program.formattedSubtitle {
+ Text(subtitle)
+ .foregroundColor(.white)
+ .font(.footnote)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ }
+
+ Text(program.formattedInterval)
+ .foregroundColor(.gray)
+ .font(.caption.monospacedDigit().bold())
+ }
+ .padding(.vertical, 8)
+
+ Spacer()
+
+ if let program = viewModel.upcomingProgram,
+ let title = program.title {
+ VStack(alignment: .trailing) {
+ Text(title)
+ .lineLimit(2)
+ .truncationMode(.middle)
+ .multilineTextAlignment(.trailing)
+ .font(.caption.leading(.tight))
+ Text("in \(program.interval.start.formattedTimeLeft)")
+ .foregroundColor(.gray)
+ .font(.caption.bold().monospacedDigit())
+ }
+ .padding(8)
+ }
+ }
+
+ ZStack(alignment: .leading) {
+ Rectangle()
+ .foregroundColor(progress == 0 ? .clear : .gray)
+ Rectangle()
+ .foregroundColor(.white)
+ .scaleEffect(x: progress, y: 1, anchor: .leading)
+ }
+ .frame(height: 3)
+ .opacity(0.5)
+ }
+ .background(.thinMaterial)
+ .cornerRadius(16)
+ .shadow(radius: 8)
+ }
+ }
+ .onAppear {
+ update()
+ updateTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { timer in
+ update()
+ }
+ }
+ .onDisappear {
+ updateTimer?.invalidate()
+ }
+ }
+
+ private func update() {
+ progress = viewModel.currentProgram?.interval.currentProgress ?? 0
+ viewModel.updatePrograms()
+ }
+}
diff --git a/Tivu/Views/Player/PlayerView.swift b/Tivu/Views/Player/PlayerView.swift
new file mode 100644
index 0000000..3e10cf3
--- /dev/null
+++ b/Tivu/Views/Player/PlayerView.swift
@@ -0,0 +1,89 @@
+import SwiftUI
+
+struct PlayerView: View {
+ let channel: Channel
+ let streamURL: URL
+
+ @ScaledMetric private var channelImageHeight = 32
+ @State private var interfaceVisible = true
+
+ @Environment(\.dismiss) var dismiss
+
+ @StateObject var player = VideoPlayer()
+
+ var body: some View {
+ GeometryReader { geo in
+ ZStack(alignment: .center) {
+ VideoView(player: player)
+ .ignoresSafeArea()
+ .onTapGesture {
+ interfaceVisible = !interfaceVisible
+ }
+
+ VStack {
+ HStack {
+ Button {
+ dismiss()
+ } label: {
+ Label("Close", systemImage: "xmark")
+ .foregroundColor(.primary)
+ .labelStyle(.iconOnly)
+ .font(.body.bold())
+ }
+ .padding()
+ .background(.regularMaterial)
+ .cornerRadius(16)
+ .shadow(radius: 8)
+
+ Spacer()
+
+ CachedImage(
+ url: channel.logoURL,
+ content: { image in
+ image
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(height: channelImageHeight)
+ },
+ placeholder: {
+ Text(channel.name)
+ .font(.caption)
+ .padding(8)
+ }
+ )
+ .padding(8)
+ .background(.thinMaterial)
+ .cornerRadius(16)
+ .shadow(radius: 8)
+ }
+
+ Spacer()
+
+ NowPlayingPill(channel: channel)
+ }
+ .frame(maxWidth: geo.size.height * 16/9)
+ .padding(8)
+ .opacity(interfaceVisible ? 1 : 0)
+
+ switch player.playbackState {
+ case .loading:
+ ProgressView()
+ .scaleEffect(2)
+ case .stopped, .error:
+ Text("Playback Error")
+ .font(.headline.bold())
+ .foregroundColor(.secondary)
+ case .playing:
+ EmptyView()
+ }
+ }
+ }
+ .preferredColorScheme(.dark)
+ .statusBar(hidden: !interfaceVisible)
+ .animation(.easeInOut(duration: 0.1), value: interfaceVisible)
+ .onAppear {
+ player.url = streamURL
+ player.play()
+ }
+ }
+}
diff --git a/Tivu/Views/Player/VideoView.swift b/Tivu/Views/Player/VideoView.swift
new file mode 100644
index 0000000..aebc956
--- /dev/null
+++ b/Tivu/Views/Player/VideoView.swift
@@ -0,0 +1,87 @@
+import SwiftUI
+import MobileVLCKit
+
+class VideoPlayer: NSObject, ObservableObject {
+ @Published var playbackState = PlaybackState.stopped
+
+ fileprivate var player: VLCMediaPlayer
+
+ var url: URL? {
+ get {
+ player.media?.url
+ }
+ set {
+ if let url = newValue {
+ if player.media?.url != url {
+ player.media = VLCMedia(url: url)
+ }
+ } else {
+ player.media = nil
+ }
+ }
+ }
+
+ init(url: URL? = nil) {
+ self.player = VLCMediaPlayer()
+ super.init()
+
+ self.url = url
+ self.player.delegate = self
+ }
+
+ func play() {
+ player.play()
+ }
+
+ func stop() {
+ player.stop()
+ }
+}
+
+struct VideoView: UIViewRepresentable {
+ let player: VideoPlayer
+
+ func makeUIView(context: Context) -> UIView {
+ let view = UIView()
+ player.player.drawable = view
+ return view
+ }
+
+ func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext) {
+ }
+}
+
+extension VideoPlayer {
+ enum PlaybackState: String, CaseIterable {
+ case stopped
+ case loading
+ case playing
+ case error
+
+ var displayName: String {
+ return rawValue.capitalized
+ }
+ }
+}
+
+extension VideoPlayer: VLCMediaPlayerDelegate {
+ func mediaPlayerStateChanged(_ aNotification: Notification) {
+ switch player.state {
+ case .stopped, .ended:
+ playbackState = .stopped
+ case .opening, .buffering:
+ playbackState = .loading
+ case .error:
+ playbackState = .error
+ default:
+ break
+ }
+ }
+
+ func mediaPlayerTimeChanged(_ aNotification: Notification) {
+ let hasProgress = player.time.intValue > 0
+ if hasProgress {
+ playbackState = .playing
+ }
+ }
+}
diff --git a/Tivu/Views/Settings/FormField.swift b/Tivu/Views/Settings/FormField.swift
new file mode 100644
index 0000000..1aa3ea5
--- /dev/null
+++ b/Tivu/Views/Settings/FormField.swift
@@ -0,0 +1,20 @@
+import SwiftUI
+
+struct FormField: View {
+ let label: String
+ @ViewBuilder let content: () -> Content
+
+ init(_ label: String, content: @escaping () -> Content) {
+ self.label = label
+ self.content = content
+ }
+
+ var body: some View {
+ HStack {
+ Text(label)
+ content()
+ .frame(maxWidth: .infinity)
+ .multilineTextAlignment(.trailing)
+ }
+ }
+}
diff --git a/Tivu/Views/Settings/SettingsView+ViewModel.swift b/Tivu/Views/Settings/SettingsView+ViewModel.swift
new file mode 100644
index 0000000..1ef0d47
--- /dev/null
+++ b/Tivu/Views/Settings/SettingsView+ViewModel.swift
@@ -0,0 +1,74 @@
+extension SettingsView {
+ @MainActor class ViewModel: ObservableObject {
+ @Published var url = ""
+ @Published var username = ""
+ @Published var password = ""
+ @Published var hideSchedule = false
+
+ @Published private(set) var showOnlySetupSettings = true
+ @Published private(set) var loading = false
+ @Published private(set) var triggerDismiss = false
+ @Published private(set) var error: Error? {
+ didSet {
+ displayError = error != nil
+ }
+ }
+ @Published var displayError = false
+
+ private let dataManager: DataManager
+ private let settings: Settings
+
+ init(
+ dataManager: DataManager = DataManager.shared,
+ settings: Settings = Settings.shared
+ ) {
+ self.dataManager = dataManager
+ self.settings = settings
+
+ let serverURL = settings.server?.url
+ self.url = serverURL?.removingBasicAuth().absoluteString ?? ""
+ self.username = serverURL?.user ?? ""
+ self.password = serverURL?.password ?? ""
+ self.hideSchedule = settings.hideSchedule
+ self.showOnlySetupSettings = serverURL == nil
+ }
+
+ var canSave: Bool {
+ newServer != nil
+ }
+
+ func save() async {
+ guard let server = newServer else { return }
+
+ defer { loading = false }
+ loading = true
+
+ if server != settings.server {
+ do {
+ try await dataManager.refresh(withDataFrom: server.xmltvURL)
+ } catch {
+ self.error = error
+ return
+ }
+ }
+
+ settings.server = server
+ settings.hideSchedule = hideSchedule
+
+ triggerDismiss = true
+ }
+
+ private var newServer: TVHeadendServer? {
+ let hasScheme = url.starts(with: "http://") || url.starts(with: "https://")
+ let url = hasScheme ? url : "http://\(url)"
+
+ guard var urlComponents = URLComponents(string: url),
+ let host = urlComponents.host, !host.isEmpty
+ else { return nil }
+
+ urlComponents.basicAuth = URL.BasicAuth(user: username, password: password)
+ guard let serverURL = urlComponents.url else { return nil }
+ return TVHeadendServer(url: serverURL)
+ }
+ }
+}
diff --git a/Tivu/Views/Settings/SettingsView.swift b/Tivu/Views/Settings/SettingsView.swift
new file mode 100644
index 0000000..40216f7
--- /dev/null
+++ b/Tivu/Views/Settings/SettingsView.swift
@@ -0,0 +1,92 @@
+import SwiftUI
+
+struct SettingsView: View {
+ @StateObject private var viewModel: ViewModel
+ @Environment(\.dismiss) private var dismiss
+
+ init() {
+ self._viewModel = StateObject(wrappedValue: ViewModel())
+ }
+
+ var body: some View {
+ NavigationView {
+ Form {
+ Section {
+ FormField("URL") {
+ TextField("http://10.0.0.1:9981", text: $viewModel.url)
+ .textContentType(.URL)
+ .keyboardType(.URL)
+ .textInputAutocapitalization(.never)
+ .disableAutocorrection(true)
+ }
+
+ FormField("Username") {
+ TextField("admin", text: $viewModel.username)
+ .textContentType(.username)
+ .textInputAutocapitalization(.never)
+ }
+
+ FormField("Password") {
+ SecureField("••••••••", text: $viewModel.password)
+ .textContentType(.password)
+ }
+ } header: {
+ Text("TVHeadend Server")
+ } footer: {
+ Text("Connection and optional authentication details")
+ }
+
+ if !viewModel.showOnlySetupSettings {
+ Section(header: Text("Display")) {
+ Toggle(isOn: $viewModel.hideSchedule) {
+ Text("Hide programs schedule")
+ }
+ }
+ }
+ }
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button(viewModel.showOnlySetupSettings ? "" : "Cancel") {
+ dismiss()
+ }
+ .disabled(viewModel.showOnlySetupSettings)
+ }
+
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") {
+ Task {
+ await viewModel.save()
+ }
+ }
+ .disabled(!viewModel.canSave)
+ }
+ }
+ .navigationTitle(viewModel.showOnlySetupSettings ? "Setup" : "Settings")
+ }
+ .interactiveDismissDisabled()
+ .onChange(of: viewModel.triggerDismiss) { triggerDismiss in
+ if triggerDismiss {
+ dismiss()
+ }
+ }
+ .overlay {
+ if viewModel.loading {
+ Rectangle()
+ .background(.clear)
+ .opacity(0.02)
+
+ VStack(spacing: 8) {
+ ProgressView()
+ Text("Loading…")
+ }
+ .padding()
+ .background(.background)
+ .cornerRadius(8)
+ .shadow(radius: 8)
+ }
+ }
+ .alert("Server Error", isPresented: $viewModel.displayError, actions: {}) {
+ Text(viewModel.error?.localizedDescription ?? "Unknown error")
+ }
+ }
+}
diff --git a/TivuTests/Domains/TVHeadend/TVHeadendServerTests.swift b/TivuTests/Domains/TVHeadend/TVHeadendServerTests.swift
new file mode 100644
index 0000000..881fb5a
--- /dev/null
+++ b/TivuTests/Domains/TVHeadend/TVHeadendServerTests.swift
@@ -0,0 +1,17 @@
+import XCTest
+@testable import Tivu
+
+class TVHeadendServerTests: XCTestCase {
+ let server = TVHeadendServer(url: URL(string: "http://example.com/tvh/?client=Tivu")!)
+
+ func testXMLTVURL() {
+ let expectedXMLTVURL = URL(string: "http://example.com/tvh/xmltv/channels?client=Tivu&lcn=1")!
+ XCTAssertEqual(server.xmltvURL, expectedXMLTVURL)
+ }
+
+ func testStreamURL() {
+ let channel = Channel(id: "743dac58ccb8d8cac5f87f88482a3d72", name: "Rai 1", number: nil, logoURL: nil)
+ let expectedStreamURL = URL(string: "http://example.com/tvh/stream/channel/743dac58ccb8d8cac5f87f88482a3d72?client=Tivu")!
+ XCTAssertEqual(server.streamURL(for: channel), expectedStreamURL)
+ }
+}
diff --git a/TivuTests/Domains/XMLTV/ProgramTests.swift b/TivuTests/Domains/XMLTV/ProgramTests.swift
new file mode 100644
index 0000000..c6571a7
--- /dev/null
+++ b/TivuTests/Domains/XMLTV/ProgramTests.swift
@@ -0,0 +1,28 @@
+import XCTest
+@testable import Tivu
+
+class ProgramTests: XCTestCase {
+ func testNumber() {
+ let number = Program.Number(xmltvString: "0 . 5 . ")!
+ XCTAssertEqual(number.season, 1)
+ XCTAssertEqual(number.episode, 6)
+ }
+
+ func testNumberWithPart() {
+ let number = Program.Number(xmltvString: "2 . 7 . 9")!
+ XCTAssertEqual(number.season, 3)
+ XCTAssertEqual(number.episode, 8)
+ }
+
+ func testNumberWithSeasonOnly() {
+ let number = Program.Number(xmltvString: "2 . . ")!
+ XCTAssertEqual(number.season, 3)
+ XCTAssertEqual(number.episode, nil)
+ }
+
+ func testNumberWithEpisodeOnly() {
+ let number = Program.Number(xmltvString: " . 4 . ")!
+ XCTAssertEqual(number.season, nil)
+ XCTAssertEqual(number.episode, 5)
+ }
+}
diff --git a/TivuTests/Domains/XMLTV/XMLTVParserTests.swift b/TivuTests/Domains/XMLTV/XMLTVParserTests.swift
new file mode 100644
index 0000000..6987b7f
--- /dev/null
+++ b/TivuTests/Domains/XMLTV/XMLTVParserTests.swift
@@ -0,0 +1,143 @@
+import XCTest
+@testable import Tivu
+
+class XMLTVParserTests: XCTestCase {
+ // MARK: - Full
+
+ let xmltvFull = """
+
+
+
+
+ Rai 1
+ 1
+
+
+
+ Soliti Ignoti
+ Il Ritorno
+ Dal Teatro delle Vittorie, 8 Ignoti, con le relative identità nascoste.
+
+ 8 . 3 .
+
+
+ """
+
+ func testParseChannelFull() {
+ let spy = parse(xmltvFull)
+
+ let expectedChannel = Channel(
+ id: "743dac58ccb8d8cac5f87f88482a3d72",
+ name: "Rai 1",
+ number: Channel.Number(1),
+ logoURL: URL(string: "https://api.superguidatv.it/v1/channels/217/logo?theme=light")!
+ )
+ XCTAssertEqual(spy.channels.count, 1)
+ XCTAssertEqual(spy.channels.first!, expectedChannel)
+ }
+
+ func testParseProgramFull() {
+ let spy = parse(xmltvFull)
+
+ let expectedProgram = Program(
+ interval: DateInterval(start: Date(timeIntervalSince1970: 1653503400), duration: 3300),
+ channelID: "743dac58ccb8d8cac5f87f88482a3d72",
+ title: "Soliti Ignoti",
+ subtitle: "Il Ritorno",
+ description: "Dal Teatro delle Vittorie, 8 Ignoti, con le relative identità nascoste.",
+ imageURL: URL(string: "https://api.superguidatv.it/v1/programs/953924349/backdrops/1")!,
+ number: Program.Number(season: 9, episode: 4)
+ )
+ XCTAssertEqual(spy.programs.count, 1)
+ XCTAssertEqual(spy.programs.first!, expectedProgram)
+ }
+
+ // MARK: - Minimal
+
+ let xmltvMinimal = """
+
+
+
+
+ Rai 1
+
+
+
+
+ """
+
+ func testParseChannelMinimal() {
+ let spy = parse(xmltvMinimal)
+
+ let expectedChannel = Channel(
+ id: "743dac58ccb8d8cac5f87f88482a3d72",
+ name: "Rai 1",
+ number: nil,
+ logoURL: nil
+ )
+ XCTAssertEqual(spy.channels.count, 1)
+ XCTAssertEqual(spy.channels.first!, expectedChannel)
+ }
+
+ func testParseProgramMinimal() {
+ let spy = parse(xmltvMinimal)
+
+ let expectedProgram = Program(
+ interval: DateInterval(start: Date(timeIntervalSince1970: 1653503400), duration: 3300),
+ channelID: "743dac58ccb8d8cac5f87f88482a3d72",
+ title: nil,
+ subtitle: nil,
+ description: nil,
+ imageURL: nil,
+ number: nil
+ )
+
+ XCTAssertEqual(spy.programs.count, 1)
+ XCTAssertEqual(spy.programs.first!, expectedProgram)
+ }
+
+ // MARK: - Invalid
+
+ func testParseEmpty() {
+ let spy = parse("")
+ XCTAssertEqual(spy.parseErrors.count, 1)
+ }
+
+ func testParseInvalidXML() {
+ let spy = parse("foo")
+ XCTAssertEqual(spy.parseErrors.count, 1)
+ }
+
+ func testParseInvalidXMLTV() {
+ let spy = parse("")
+ XCTAssertEqual(spy.parseErrors.count, 1)
+ }
+
+ // MARK: - Helpers
+
+ class SpyDelegate: XMLTVParserDelegate {
+ var channels = [Channel]()
+ var programs = [Program]()
+ var parseErrors = [Error]()
+
+ func parser(_ parser: XMLTVParser, didFindChannel channel: Channel) {
+ channels.append(channel)
+ }
+
+ func parser(_ parser: XMLTVParser, didFindProgram program: Program) {
+ programs.append(program)
+ }
+
+ func parser(_ parser: XMLTVParser, parseErrorOccurred parseError: Error) {
+ parseErrors.append(parseError)
+ }
+ }
+
+ func parse(_ xmltv: String) -> SpyDelegate {
+ let parser = XMLTVParser(data: xmltv.data(using: .utf8)!)
+ let delegate = SpyDelegate()
+ parser.delegate = delegate
+ parser.parse()
+ return delegate
+ }
+}
diff --git a/TivuTests/Persistence/CacheManagerTests.swift b/TivuTests/Persistence/CacheManagerTests.swift
new file mode 100644
index 0000000..f26ef1d
--- /dev/null
+++ b/TivuTests/Persistence/CacheManagerTests.swift
@@ -0,0 +1,89 @@
+import XCTest
+import CoreData
+@testable import Tivu
+
+class CacheManagerTests: XCTestCase {
+ func testRefreshSave() async throws {
+ let cacheManager = CacheManager(inMemory: true)
+ try await cacheManager.refresh { context in
+ for _ in 1...3 {
+ self.randomEntity(context: context)
+ }
+ }
+
+ XCTAssertEqual(storedEntities(cacheManager: cacheManager).count, 3)
+ }
+
+ func testRefreshClear() async throws {
+ let cacheManager = CacheManager(inMemory: true)
+ try await cacheManager.refresh { context in
+ for _ in 1...7 {
+ self.randomEntity(context: context)
+ }
+ }
+ try await cacheManager.refresh { context in
+ for _ in 1...4 {
+ self.randomEntity(context: context)
+ }
+ }
+
+ XCTAssertEqual(storedEntities(cacheManager: cacheManager).count, 4)
+ }
+
+ func testInplaceUpdate() async throws {
+ var firstChannel: CachedChannel {
+ return storedEntities(cacheManager: cacheManager).first! as! CachedChannel
+ }
+
+ let cacheManager = CacheManager(inMemory: true)
+
+ try await cacheManager.refresh { context in
+ let channel = CachedChannel(context: context)
+ channel.id = "bd7dc4dde067f0bf01343a7998653cb2"
+ channel.name = "original"
+ channel.number = "10.0"
+ }
+ let objectID1 = firstChannel.objectID.uriRepresentation()
+
+ try await cacheManager.refresh { context in
+ let channel = CachedChannel(context: context)
+ channel.id = "bd7dc4dde067f0bf01343a7998653cb2"
+ channel.name = "updated"
+ channel.number = nil
+ }
+ let objectID2 = firstChannel.objectID.uriRepresentation()
+
+ XCTAssertEqual(storedEntities(cacheManager: cacheManager).count, 1)
+ XCTAssertEqual(objectID1, objectID2)
+ }
+
+ func testRollback() async throws {
+ let cacheManager = CacheManager(inMemory: true)
+
+ try await cacheManager.refresh { context in
+ for _ in 1...4 {
+ self.randomEntity(context: context)
+ }
+ context.rollback()
+ self.randomEntity(context: context)
+ }
+
+ XCTAssertEqual(storedEntities(cacheManager: cacheManager).count, 1)
+ }
+
+ // MARK: - Helpers
+
+ func storedEntities(cacheManager: CacheManager) -> [CachedEntity] {
+ cacheManager.container.viewContext.performAndWait {
+ let fetchRequest = NSFetchRequest(entityName: CachedEntity.entity().name!)
+ let result = try! cacheManager.container.viewContext.fetch(fetchRequest)
+ return result
+ }
+ }
+
+ func randomEntity(context: NSManagedObjectContext) {
+ let channel = CachedChannel(context: context)
+ channel.id = UUID().uuidString
+ channel.name = UUID().uuidString
+ }
+}