diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 000000000..3b1a6767f --- /dev/null +++ b/.swiftformat @@ -0,0 +1,8 @@ +# format options +--swiftversion 5.10 + +# file options +--exclude Pods,Generated,build,archive,fastlane,vendor + +# rules +--disable trailingCommas,wrapMultilineStatementBraces \ No newline at end of file diff --git a/.xcode-version b/.xcode-version index 8e0f41140..441e3fb38 100644 --- a/.xcode-version +++ b/.xcode-version @@ -1 +1 @@ -15.3 \ No newline at end of file +15.4 \ No newline at end of file diff --git a/Application/Resources/Apps/Play RSI/ApplicationConfiguration.json b/Application/Resources/Apps/Play RSI/ApplicationConfiguration.json index ee972a097..b2928811c 100755 --- a/Application/Resources/Apps/Play RSI/ApplicationConfiguration.json +++ b/Application/Resources/Apps/Play RSI/ApplicationConfiguration.json @@ -21,11 +21,11 @@ "radioChannels": "[{\"uid\":\"rete-uno\",\"name\":\"Rete Uno\",\"resourceUid\":\"rete_uno\",\"songsViewStyle\":\"collapsed\",\"color\":\"#0074C2\",\"secondColor\":\"#54B8EF\"},{\"uid\":\"rete-due\",\"name\":\"Rete Due\",\"resourceUid\":\"rete_due\",\"songsViewStyle\":\"collapsed\",\"color\":\"#06A73B\",\"secondColor\":\"#30E96B\"},{\"uid\":\"rete-tre\",\"name\":\"Rete Tre\",\"resourceUid\":\"rete_tre\",\"songsViewStyle\":\"collapsed\",\"color\":\"#A4BB1B\",\"secondColor\":\"#DEF355\"},{\"uid\":\"podcast\",\"name\":\"Podcast\",\"resourceUid\":\"rsi_podcast\",\"color\":\"#333333\",\"homeSections\":\"radioLatest,radioFavoriteShows,radioLatestEpisodesFromFavorites,radioResumePlayback,radioMostPopular,radioWatchLater,radioAllShows\"}]", "tvChannels": "[{\"uid\":\"la1\",\"name\":\"LA 1\",\"resourceUid\":\"la1\",\"color\":\"#FF9120\",\"secondColor\":\"#E15100\"},{\"uid\":\"la2\",\"name\":\"LA 2\",\"resourceUid\":\"la2\",\"color\":\"#FFCF2F\",\"secondColor\":\"#F38A0D\"},{\"uid\":\"143932a79bb5a123a646b68b1d1188d7ae493e5b\",\"name\":\"RTS 1\",\"resourceUid\":\"rts_un\",\"color\":\"#00D6F3\",\"secondColor\":\"#00B6F0\",\"titleColor\":\"#161616\"},{\"uid\":\"d7dfff28deee44e1d3c49a3d37d36d492b29671b\",\"name\":\"RTS 2\",\"resourceUid\":\"rts_deux\",\"color\":\"#BB66FF\",\"secondColor\":\"#782EB5\"},{\"uid\":\"5d332a26e06d08eec8ad385d566187df72955623\",\"name\":\"RTS Info\",\"resourceUid\":\"rts_info\",\"color\":\"#3787FF\",\"secondColor\":\"#153567\"},{\"uid\":\"23FFBE1B-65CE-4188-ADD2-C724186C2C9F\",\"name\":\"SRF 1\",\"resourceUid\":\"tv_srf1\",\"color\":\"#C91024\",\"secondColor\":\"#8D0614\"},{\"uid\":\"E4D5AD08-C1E8-46A3-BB58-4875051D60D2\",\"name\":\"SRF zwei\",\"resourceUid\":\"tv_srf2\",\"color\":\"#FFB600\",\"secondColor\":\"#ED7004\",\"titleColor\":\"#161616\",\"hasDarkStatusBar\":true},{\"uid\":\"34c2819e-e715-43d7-9026-40a443152a97\",\"name\":\"SRF info\",\"resourceUid\":\"tv_srf_info\",\"color\":\"#AF001E\",\"secondColor\":\"#830512\"}]", "satelliteRadioChannels": "[{\"uid\":\"rsp\",\"name\":\"Radio Swiss Pop\",\"resourceUid\":\"rsp\",\"songsViewStyle\":\"expanded\",\"color\":\"#F01F73\",\"secondColor\":\"#D31A3C\",\"homepageHidden\":true, \"shareURL\":\"https://www.radioswisspop.ch/it\"},{\"uid\":\"rsc-it\",\"name\":\"Radio Swiss Classic\",\"resourceUid\":\"rsc\",\"songsViewStyle\":\"expanded\",\"color\":\"#09A1DE\",\"secondColor\":\"#036E99\",\"homepageHidden\":true, \"shareURL\":\"https://www.radioswissclassic.ch/it\"},{\"uid\":\"rsj\",\"name\":\"Radio Swiss Jazz\",\"resourceUid\":\"rsj\",\"songsViewStyle\":\"expanded\",\"color\":\"#F7B222\",\"secondColor\":\"#CC7A00\",\"homepageHidden\":true, \"shareURL\":\"https://www.radioswissjazz.ch/it\"}]", - "topicColors": "{\"urn:rsi:topic:tv:1\":{\"firstColor\":\"#c01232\",\"secondColor\":\"#480010\"},\"urn:rsi:topic:tv:4\":{\"firstColor\":\"#d7b447\",\"secondColor\":\"#b62019\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:7\":{\"firstColor\":\"#da2146\",\"secondColor\":\"#2d38c0\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:8\":{\"firstColor\":\"#cd4023\",\"secondColor\":\"#90062e\"},\"urn:rsi:topic:tv:11\":{\"firstColor\":\"#dea706\",\"secondColor\":\"#bd2e5e\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:40\":{\"firstColor\":\"#44bda8\",\"secondColor\":\"#00324e\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:80\":{\"firstColor\":\"#1f509d\",\"secondColor\":\"#121a37\"},\"urn:rsi:topic:tv:90\":{\"firstColor\":\"#738dae\",\"secondColor\":\"#3a465e\"},\"urn:rsi:topic:tv:100\":{\"firstColor\":\"#d75959\",\"secondColor\":\"#29336c\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:600\":{\"firstColor\":\"#27DCF9\",\"secondColor\":\"#932387\"},\"urn:rsi:topic:tv:6000\":{\"firstColor\":\"#02cde9\",\"secondColor\":\"#011844\"},\"urn:rtr:topic:tv:2d48ba80-566c-4359-9e8d-8d9b2d570e0a\":{\"firstColor\":\"#00A1A1\",\"secondColor\":\"#04575B\"},\"urn:rtr:topic:tv:7d7f21be-6727-4939-9126-5bca25eb3a49\":{\"firstColor\":\"#80D2E3\",\"secondColor\":\"#003D58\"},\"urn:rtr:topic:tv:20e7478f-1ea1-49c3-81c2-5f157d6ff092\":{\"firstColor\":\"#340101\",\"secondColor\":\"#8F0E0F\"},\"urn:rtr:topic:tv:50bb90d6-41af-4bbd-b92c-6ef5db16a9b3\":{\"firstColor\":\"#8A0533\",\"secondColor\":\"#812626\"},\"urn:rtr:topic:tv:c50140e7-5740-4c44-abd0-0f7d9ea68da7\":{\"firstColor\":\"#A6A6A7\",\"secondColor\":\"#2C2B2D\"},\"urn:rtr:topic:tv:dfb7ae6d-cb73-431b-a817-b1663ec2f58a\":{\"firstColor\":\"#00F8CC\",\"secondColor\":\"#018864\"},\"urn:rts:topic:tv:623\":{\"firstColor\":\"#5C845B\",\"secondColor\":\"#16280F\"},\"urn:rts:topic:tv:665\":{\"firstColor\":\"#3787FF\",\"secondColor\":\"#0A1C33\"},\"urn:rts:topic:tv:1095\":{\"firstColor\":\"#F5F500\",\"secondColor\":\"#BEB405\",\"reduceBrightness\":true},\"urn:rts:topic:tv:1353\":{\"firstColor\":\"#084165\",\"secondColor\":\"#140953\"},\"urn:rts:topic:tv:2743\":{\"firstColor\":\"#BCF6FF\",\"secondColor\":\"#00D0EF\",\"reduceBrightness\":true},\"urn:rts:topic:tv:10193\":{\"firstColor\":\"#EB2350\",\"secondColor\":\"#A61637\"},\"urn:rts:topic:tv:54537\":{\"firstColor\":\"#FFE03E\",\"secondColor\":\"#F98E73\",\"reduceBrightness\":true},\"urn:rts:topic:tv:59220\":{\"firstColor\":\"#492b63\",\"secondColor\":\"#271633\"},\"urn:rts:topic:tv:67132\":{\"firstColor\":\"#415FAF\",\"secondColor\":\"#23376B\"},\"urn:srf:topic:tv:1d7d9cfb-6682-4d5b-9e36-322e8fa93c03\":{\"firstColor\":\"#00A4B3\",\"secondColor\":\"#006973\"},\"urn:srf:topic:tv:4acf86dd-7ff7-45d3-baf8-33375340d976\":{\"firstColor\":\"#3f4b70\",\"secondColor\":\"#131a2d\"},\"urn:srf:topic:tv:9a79b1de-cde8-4528-b304-d1ae1363f52f\":{\"firstColor\":\"#836fcd\",\"secondColor\":\"#36343f\"},\"urn:srf:topic:tv:63f937e4-859e-42c4-a430-bdb74dd09645\":{\"firstColor\":\"#4480a2\",\"secondColor\":\"#20182c\"},\"urn:srf:topic:tv:67f812fd-19a3-4c22-9e6b-ec36e65a4703\":{\"firstColor\":\"#bb3966\",\"secondColor\":\"#190406\"},\"urn:srf:topic:tv:593eb926-d892-41ba-8b1f-eccbcfd7f15f\":{\"firstColor\":\"#2bbf9b\",\"secondColor\":\"#02291e\"},\"urn:srf:topic:tv:649e36d7-ff57-41c8-9c1b-7892daf15e78\":{\"firstColor\":\"#FF0037\",\"secondColor\":\"#AF001E\"},\"urn:srf:topic:tv:882cb264-cf81-4a9c-b660-d42519b7ce28\":{\"firstColor\":\"#c91d7d\",\"secondColor\":\"#31041f\"},\"urn:srf:topic:tv:43741c59-317e-458b-ac38-c2b1c065c865\":{\"firstColor\":\"#0075ad\",\"secondColor\":\"#000022\"},\"urn:srf:topic:tv:516421f0-ec89-43ba-823b-1b5ceec262f3\":{\"firstColor\":\"#5FB281\",\"secondColor\":\"#154e60\"},\"urn:srf:topic:tv:641223fa-f112-4d98-8aec-cb22262a1182\":{\"firstColor\":\"#c55cee\",\"secondColor\":\"#0c1c68\"},\"urn:srf:topic:tv:a2d97206-0b85-4226-8afe-06e86ebd05b2\":{\"firstColor\":\"#9fc885\",\"secondColor\":\"#20281a\"},\"urn:srf:topic:tv:a709c610-b275-4c0c-a496-cba304c36712\":{\"firstColor\":\"#b3131d\",\"secondColor\":\"#3e0b14\"},\"urn:srf:topic:tv:b58dcf14-96ac-4046-8676-fd8a942c0e88\":{\"firstColor\":\"#7081b0\",\"secondColor\":\"#202020\"},\"urn:srf:topic:tv:bb7b21e0-1056-4e28-bac3-c610393b5b0f\":{\"firstColor\":\"#3c788e\",\"secondColor\":\"#1b3e48\"},\"urn:srf:topic:tv:e52080fc-f36b-481e-955f-071b6c8d6dc3\":{\"firstColor\":\"#ff6778\",\"secondColor\":\"#920a1a\",\"reduceBrightness\":true},\"urn:srf:topic:tv:fa793c13-bebc-41b9-9710-bf8a34192c15\":{\"firstColor\":\"#baead5\",\"secondColor\":\"#010b40\",\"reduceBrightness\":true}}", + "topicColors": "{\"urn:rsi:topic:tv:1\":{\"firstColor\":\"#B5344E\",\"secondColor\":\"#480010\"},\"urn:rsi:topic:tv:4\":{\"firstColor\":\"#D7B447\",\"secondColor\":\"#B62019\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:7\":{\"firstColor\":\"#546591\",\"secondColor\":\"#2C3A50\"},\"urn:rsi:topic:tv:8\":{\"firstColor\":\"#CD4023\",\"secondColor\":\"#90062E\"},\"urn:rsi:topic:tv:11\":{\"firstColor\":\"#DEA706\",\"secondColor\":\"#E92466\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:40\":{\"firstColor\":\"#44BDA8\",\"secondColor\":\"#00324E\"},\"urn:rsi:topic:tv:80\":{\"firstColor\":\"#1F509D\",\"secondColor\":\"#121A37\"},\"urn:rsi:topic:tv:90\":{\"firstColor\":\"#8B96A5\",\"secondColor\":\"#4F5562\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:100\":{\"firstColor\":\"#D75959\",\"secondColor\":\"#29336C\"},\"urn:rsi:topic:tv:600\":{\"firstColor\":\"#23B6CD\",\"secondColor\":\"#7C3184\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:6000\":{\"firstColor\":\"#017EB3\",\"secondColor\":\"#011844\"},\"urn:rtr:topic:tv:2d48ba80-566c-4359-9e8d-8d9b2d570e0a\":{\"firstColor\":\"#00A1A1\",\"secondColor\":\"#04575B\"},\"urn:rtr:topic:tv:7d7f21be-6727-4939-9126-5bca25eb3a49\":{\"firstColor\":\"#80D2E3\",\"secondColor\":\"#003D58\"},\"urn:rtr:topic:tv:20e7478f-1ea1-49c3-81c2-5f157d6ff092\":{\"firstColor\":\"#340101\",\"secondColor\":\"#8F0E0F\"},\"urn:rtr:topic:tv:50bb90d6-41af-4bbd-b92c-6ef5db16a9b3\":{\"firstColor\":\"#8A0533\",\"secondColor\":\"#812626\"},\"urn:rtr:topic:tv:c50140e7-5740-4c44-abd0-0f7d9ea68da7\":{\"firstColor\":\"#A6A6A7\",\"secondColor\":\"#2C2B2D\"},\"urn:rtr:topic:tv:dfb7ae6d-cb73-431b-a817-b1663ec2f58a\":{\"firstColor\":\"#00F8CC\",\"secondColor\":\"#018864\"},\"urn:rts:topic:tv:623\":{\"firstColor\":\"#5C845B\",\"secondColor\":\"#16280F\"},\"urn:rts:topic:tv:665\":{\"firstColor\":\"#3787FF\",\"secondColor\":\"#0A1C33\"},\"urn:rts:topic:tv:1095\":{\"firstColor\":\"#F5F500\",\"secondColor\":\"#BEB405\",\"reduceBrightness\":true},\"urn:rts:topic:tv:1353\":{\"firstColor\":\"#084165\",\"secondColor\":\"#140953\"},\"urn:rts:topic:tv:2743\":{\"firstColor\":\"#BCF6FF\",\"secondColor\":\"#00D0EF\",\"reduceBrightness\":true},\"urn:rts:topic:tv:10193\":{\"firstColor\":\"#EB2350\",\"secondColor\":\"#A61637\"},\"urn:rts:topic:tv:54537\":{\"firstColor\":\"#FFE03E\",\"secondColor\":\"#F98E73\",\"reduceBrightness\":true},\"urn:rts:topic:tv:59220\":{\"firstColor\":\"#492b63\",\"secondColor\":\"#271633\"},\"urn:rts:topic:tv:67132\":{\"firstColor\":\"#415FAF\",\"secondColor\":\"#23376B\"},\"urn:srf:topic:tv:1d7d9cfb-6682-4d5b-9e36-322e8fa93c03\":{\"firstColor\":\"#00A4B3\",\"secondColor\":\"#006973\"},\"urn:srf:topic:tv:4acf86dd-7ff7-45d3-baf8-33375340d976\":{\"firstColor\":\"#3f4b70\",\"secondColor\":\"#131a2d\"},\"urn:srf:topic:tv:9a79b1de-cde8-4528-b304-d1ae1363f52f\":{\"firstColor\":\"#836fcd\",\"secondColor\":\"#36343f\"},\"urn:srf:topic:tv:63f937e4-859e-42c4-a430-bdb74dd09645\":{\"firstColor\":\"#4480a2\",\"secondColor\":\"#20182c\"},\"urn:srf:topic:tv:67f812fd-19a3-4c22-9e6b-ec36e65a4703\":{\"firstColor\":\"#bb3966\",\"secondColor\":\"#190406\"},\"urn:srf:topic:tv:593eb926-d892-41ba-8b1f-eccbcfd7f15f\":{\"firstColor\":\"#2bbf9b\",\"secondColor\":\"#02291e\"},\"urn:srf:topic:tv:649e36d7-ff57-41c8-9c1b-7892daf15e78\":{\"firstColor\":\"#FF0037\",\"secondColor\":\"#AF001E\"},\"urn:srf:topic:tv:882cb264-cf81-4a9c-b660-d42519b7ce28\":{\"firstColor\":\"#c91d7d\",\"secondColor\":\"#31041f\"},\"urn:srf:topic:tv:43741c59-317e-458b-ac38-c2b1c065c865\":{\"firstColor\":\"#0075ad\",\"secondColor\":\"#000022\"},\"urn:srf:topic:tv:516421f0-ec89-43ba-823b-1b5ceec262f3\":{\"firstColor\":\"#5FB281\",\"secondColor\":\"#154e60\"},\"urn:srf:topic:tv:641223fa-f112-4d98-8aec-cb22262a1182\":{\"firstColor\":\"#c55cee\",\"secondColor\":\"#0c1c68\"},\"urn:srf:topic:tv:a2d97206-0b85-4226-8afe-06e86ebd05b2\":{\"firstColor\":\"#9fc885\",\"secondColor\":\"#20281a\"},\"urn:srf:topic:tv:a709c610-b275-4c0c-a496-cba304c36712\":{\"firstColor\":\"#b3131d\",\"secondColor\":\"#3e0b14\"},\"urn:srf:topic:tv:b58dcf14-96ac-4046-8676-fd8a942c0e88\":{\"firstColor\":\"#7081b0\",\"secondColor\":\"#202020\"},\"urn:srf:topic:tv:bb7b21e0-1056-4e28-bac3-c610393b5b0f\":{\"firstColor\":\"#3c788e\",\"secondColor\":\"#1b3e48\"},\"urn:srf:topic:tv:e52080fc-f36b-481e-955f-071b6c8d6dc3\":{\"firstColor\":\"#ff6778\",\"secondColor\":\"#920a1a\",\"reduceBrightness\":true},\"urn:srf:topic:tv:fa793c13-bebc-41b9-9710-bf8a34192c15\":{\"firstColor\":\"#baead5\",\"secondColor\":\"#010b40\",\"reduceBrightness\":true}}", "continuousPlaybackPlayerViewTransitionDuration": 10, "continuousPlaybackForegroundTransitionDuration": 0, "continuousPlaybackBackgroundTransitionDuration": 0, - "endToleranceRatio": 0.02, + "endToleranceRatio": 0.07, "hiddenOnboardings": "account,favorites_account,resume_playback_account,watch_later_account", "searchSettingSubtitledHidden": true, "subtitleAvailabilityHidden": true, diff --git a/Application/Resources/Apps/Play RSI/it.lproj/Localizable.strings b/Application/Resources/Apps/Play RSI/it.lproj/Localizable.strings index 0dc770275..e297e4b64 100755 --- a/Application/Resources/Apps/Play RSI/it.lproj/Localizable.strings +++ b/Application/Resources/Apps/Play RSI/it.lproj/Localizable.strings @@ -95,6 +95,10 @@ /* Audio description availability setting label */ "Audio description availability" = "Audiodescrizione disponibile"; +/* Audio home page selection view title + Label of the button for audio homepage option selection */ +"Audio home page" = "Audio home page"; + /* Audios tab title Header for audio search results Search setting option @@ -159,7 +163,9 @@ /* Search setting */ "Date" = "Data"; -/* Poster images setting state */ +/* Audio homepage option setting state + Poster images setting state + Square images setting state */ "Default (current configuration)" = "Default (configurazione attuale)"; /* User location setting state */ @@ -270,7 +276,8 @@ /* Explanation displayed in the alert asking the user to enable notifications */ "For the application to inform you when a new episode is available, notifications must be enabled." = "Affinché l'app ti possa informare quando è disponibile un nuovo episodio, è necessario attivare le notifiche."; -/* Poster images setting state */ +/* Poster images setting state + Square images setting state */ "Force" = "Forzare"; /* Total free space size displayed as a list footer */ @@ -299,7 +306,8 @@ /* Home tab title */ "Home" = "Home"; -/* Poster images setting state */ +/* Poster images setting state + Square images setting state */ "Ignore" = "Ignorare"; /* User location setting state */ @@ -381,6 +389,12 @@ /* Message title displayed when the user is forced to update the application. */ "Mandatory update" = "È necessario aggiornare"; +/* Many curated audio homepages option setting state */ +"Many curated pages (PAC landing pages)" = "Many curated pages (PAC landing pages)"; + +/* Many predefined audio homepage option setting state */ +"Many predefined pages" = "Many predefined pages"; + /* Message on top screen when trying to open a media in the download list and the media is not downloaded. */ "Media not available yet" = "Media non disponibile"; @@ -473,6 +487,9 @@ Title of the search settings button to apply settings */ "OK" = "OK"; +/* One curated audio homepage option setting state */ +"One curated page (PAC Audio)" = "One curated page (PAC Audio)"; + /* Label of the button opening system settings */ "Open system settings" = "Aprire le impostazioni del sistema"; @@ -661,6 +678,10 @@ Title label used to present sport scheduled livestream medias */ "Sport livestreams" = "Sport in diretta"; +/* Label of the button for Podcast square image format selection + Podcast square image format selection view title */ +"Square images" = "Square images"; + /* Server setting name */ "Stage" = "Stage"; diff --git a/Application/Resources/Apps/Play RTR/ApplicationConfiguration.json b/Application/Resources/Apps/Play RTR/ApplicationConfiguration.json index e7c91787e..f8f4cf1aa 100755 --- a/Application/Resources/Apps/Play RTR/ApplicationConfiguration.json +++ b/Application/Resources/Apps/Play RTR/ApplicationConfiguration.json @@ -19,11 +19,11 @@ "radioChannels": "[{\"uid\":\"12fb886e-b7aa-4e55-beb2-45dbc619f3c4\",\"name\":\"Radio RTR\",\"resourceUid\":\"radio_rtr\",\"songsViewStyle\":\"expanded\",\"color\":\"#AF001D\",\"secondColor\":\"#9B001B\"}]", "tvChannels": "[{\"uid\":\"la1\",\"name\":\"LA 1\",\"resourceUid\":\"la1\",\"color\":\"#FF9120\",\"secondColor\":\"#E15100\"},{\"uid\":\"la2\",\"name\":\"LA 2\",\"resourceUid\":\"la2\",\"color\":\"#FFCF2F\",\"secondColor\":\"#F38A0D\"},{\"uid\":\"f5dc82ed-4564-4223-903f-0bf6a13c5620\",\"name\":\"RTR auf SRF 1\",\"resourceUid\":\"rtr_srf1\",\"color\":\"#C91024\",\"secondColor\":\"#8D0614\"},{\"uid\":\"80bdf859-b58d-421d-bb27-ce1fba4637a7\",\"name\":\"RTR auf SRF Info\",\"resourceUid\":\"rtr_srf_info\",\"color\":\"#AF001E\",\"secondColor\":\"#830512\"},{\"uid\":\"2541c864-f883-4b80-9459-e1026e0e692e\",\"name\":\"RTR auf SRF 2\",\"resourceUid\":\"rtr_srf2\",\"color\":\"#FFB600\",\"secondColor\":\"#ED7004\",\"titleColor\":\"#333333\",\"hasDarkStatusBar\":true},{\"uid\":\"143932a79bb5a123a646b68b1d1188d7ae493e5b\",\"name\":\"RTS 1\",\"resourceUid\":\"rts_un\",\"color\":\"#00D6F3\",\"secondColor\":\"#00B6F0\",\"titleColor\":\"#161616\"},{\"uid\":\"d7dfff28deee44e1d3c49a3d37d36d492b29671b\",\"name\":\"RTS 2\",\"resourceUid\":\"rts_deux\",\"color\":\"#BB66FF\",\"secondColor\":\"#782EB5\"},{\"uid\":\"5d332a26e06d08eec8ad385d566187df72955623\",\"name\":\"RTS Info\",\"resourceUid\":\"rts_info\",\"color\":\"#3787FF\",\"secondColor\":\"#153567\"}]", "satelliteRadioChannels": "[{\"uid\":\"rsp\",\"name\":\"Radio Swiss Pop\",\"resourceUid\":\"rsp\",\"songsViewStyle\":\"expanded\",\"color\":\"#F01F73\",\"secondColor\":\"#D31A3C\",\"homepageHidden\":true, \"shareURL\":\"https://www.radioswisspop.ch/de\"},{\"uid\":\"rsc-de\",\"name\":\"Radio Swiss Classic\",\"resourceUid\":\"rsc\",\"songsViewStyle\":\"expanded\",\"color\":\"#09A1DE\",\"secondColor\":\"#036E99\",\"homepageHidden\":true, \"shareURL\":\"https://www.radioswissclassic.ch/de\"},{\"uid\":\"rsj\",\"name\":\"Radio Swiss Jazz\",\"resourceUid\":\"rsj\",\"songsViewStyle\":\"expanded\",\"color\":\"#F7B222\",\"secondColor\":\"#CC7A00\",\"homepageHidden\":true, \"shareURL\":\"https://www.radioswissjazz.ch/de\"}]", - "topicColors": "{\"urn:rsi:topic:tv:1\":{\"firstColor\":\"#c01232\",\"secondColor\":\"#480010\"},\"urn:rsi:topic:tv:4\":{\"firstColor\":\"#d7b447\",\"secondColor\":\"#b62019\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:7\":{\"firstColor\":\"#da2146\",\"secondColor\":\"#2d38c0\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:8\":{\"firstColor\":\"#cd4023\",\"secondColor\":\"#90062e\"},\"urn:rsi:topic:tv:11\":{\"firstColor\":\"#dea706\",\"secondColor\":\"#bd2e5e\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:40\":{\"firstColor\":\"#44bda8\",\"secondColor\":\"#00324e\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:80\":{\"firstColor\":\"#1f509d\",\"secondColor\":\"#121a37\"},\"urn:rsi:topic:tv:90\":{\"firstColor\":\"#738dae\",\"secondColor\":\"#3a465e\"},\"urn:rsi:topic:tv:100\":{\"firstColor\":\"#d75959\",\"secondColor\":\"#29336c\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:600\":{\"firstColor\":\"#27DCF9\",\"secondColor\":\"#932387\"},\"urn:rsi:topic:tv:6000\":{\"firstColor\":\"#02cde9\",\"secondColor\":\"#011844\"},\"urn:rtr:topic:tv:2d48ba80-566c-4359-9e8d-8d9b2d570e0a\":{\"firstColor\":\"#00A1A1\",\"secondColor\":\"#04575B\"},\"urn:rtr:topic:tv:7d7f21be-6727-4939-9126-5bca25eb3a49\":{\"firstColor\":\"#80D2E3\",\"secondColor\":\"#003D58\"},\"urn:rtr:topic:tv:20e7478f-1ea1-49c3-81c2-5f157d6ff092\":{\"firstColor\":\"#340101\",\"secondColor\":\"#8F0E0F\"},\"urn:rtr:topic:tv:50bb90d6-41af-4bbd-b92c-6ef5db16a9b3\":{\"firstColor\":\"#8A0533\",\"secondColor\":\"#812626\"},\"urn:rtr:topic:tv:c50140e7-5740-4c44-abd0-0f7d9ea68da7\":{\"firstColor\":\"#A6A6A7\",\"secondColor\":\"#2C2B2D\"},\"urn:rtr:topic:tv:dfb7ae6d-cb73-431b-a817-b1663ec2f58a\":{\"firstColor\":\"#00F8CC\",\"secondColor\":\"#018864\"},\"urn:rts:topic:tv:623\":{\"firstColor\":\"#5C845B\",\"secondColor\":\"#16280F\"},\"urn:rts:topic:tv:665\":{\"firstColor\":\"#3787FF\",\"secondColor\":\"#0A1C33\"},\"urn:rts:topic:tv:1095\":{\"firstColor\":\"#F5F500\",\"secondColor\":\"#BEB405\",\"reduceBrightness\":true},\"urn:rts:topic:tv:1353\":{\"firstColor\":\"#084165\",\"secondColor\":\"#140953\"},\"urn:rts:topic:tv:2743\":{\"firstColor\":\"#BCF6FF\",\"secondColor\":\"#00D0EF\",\"reduceBrightness\":true},\"urn:rts:topic:tv:10193\":{\"firstColor\":\"#EB2350\",\"secondColor\":\"#A61637\"},\"urn:rts:topic:tv:54537\":{\"firstColor\":\"#FFE03E\",\"secondColor\":\"#F98E73\",\"reduceBrightness\":true},\"urn:rts:topic:tv:59220\":{\"firstColor\":\"#492b63\",\"secondColor\":\"#271633\"},\"urn:rts:topic:tv:67132\":{\"firstColor\":\"#415FAF\",\"secondColor\":\"#23376B\"},\"urn:srf:topic:tv:1d7d9cfb-6682-4d5b-9e36-322e8fa93c03\":{\"firstColor\":\"#00A4B3\",\"secondColor\":\"#006973\"},\"urn:srf:topic:tv:4acf86dd-7ff7-45d3-baf8-33375340d976\":{\"firstColor\":\"#3f4b70\",\"secondColor\":\"#131a2d\"},\"urn:srf:topic:tv:9a79b1de-cde8-4528-b304-d1ae1363f52f\":{\"firstColor\":\"#836fcd\",\"secondColor\":\"#36343f\"},\"urn:srf:topic:tv:63f937e4-859e-42c4-a430-bdb74dd09645\":{\"firstColor\":\"#4480a2\",\"secondColor\":\"#20182c\"},\"urn:srf:topic:tv:67f812fd-19a3-4c22-9e6b-ec36e65a4703\":{\"firstColor\":\"#bb3966\",\"secondColor\":\"#190406\"},\"urn:srf:topic:tv:593eb926-d892-41ba-8b1f-eccbcfd7f15f\":{\"firstColor\":\"#2bbf9b\",\"secondColor\":\"#02291e\"},\"urn:srf:topic:tv:649e36d7-ff57-41c8-9c1b-7892daf15e78\":{\"firstColor\":\"#FF0037\",\"secondColor\":\"#AF001E\"},\"urn:srf:topic:tv:882cb264-cf81-4a9c-b660-d42519b7ce28\":{\"firstColor\":\"#c91d7d\",\"secondColor\":\"#31041f\"},\"urn:srf:topic:tv:43741c59-317e-458b-ac38-c2b1c065c865\":{\"firstColor\":\"#0075ad\",\"secondColor\":\"#000022\"},\"urn:srf:topic:tv:516421f0-ec89-43ba-823b-1b5ceec262f3\":{\"firstColor\":\"#5FB281\",\"secondColor\":\"#154e60\"},\"urn:srf:topic:tv:641223fa-f112-4d98-8aec-cb22262a1182\":{\"firstColor\":\"#c55cee\",\"secondColor\":\"#0c1c68\"},\"urn:srf:topic:tv:a2d97206-0b85-4226-8afe-06e86ebd05b2\":{\"firstColor\":\"#9fc885\",\"secondColor\":\"#20281a\"},\"urn:srf:topic:tv:a709c610-b275-4c0c-a496-cba304c36712\":{\"firstColor\":\"#b3131d\",\"secondColor\":\"#3e0b14\"},\"urn:srf:topic:tv:b58dcf14-96ac-4046-8676-fd8a942c0e88\":{\"firstColor\":\"#7081b0\",\"secondColor\":\"#202020\"},\"urn:srf:topic:tv:bb7b21e0-1056-4e28-bac3-c610393b5b0f\":{\"firstColor\":\"#3c788e\",\"secondColor\":\"#1b3e48\"},\"urn:srf:topic:tv:e52080fc-f36b-481e-955f-071b6c8d6dc3\":{\"firstColor\":\"#ff6778\",\"secondColor\":\"#920a1a\",\"reduceBrightness\":true},\"urn:srf:topic:tv:fa793c13-bebc-41b9-9710-bf8a34192c15\":{\"firstColor\":\"#baead5\",\"secondColor\":\"#010b40\",\"reduceBrightness\":true}}", + "topicColors": "{\"urn:rsi:topic:tv:1\":{\"firstColor\":\"#B5344E\",\"secondColor\":\"#480010\"},\"urn:rsi:topic:tv:4\":{\"firstColor\":\"#D7B447\",\"secondColor\":\"#B62019\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:7\":{\"firstColor\":\"#546591\",\"secondColor\":\"#2C3A50\"},\"urn:rsi:topic:tv:8\":{\"firstColor\":\"#CD4023\",\"secondColor\":\"#90062E\"},\"urn:rsi:topic:tv:11\":{\"firstColor\":\"#DEA706\",\"secondColor\":\"#E92466\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:40\":{\"firstColor\":\"#44BDA8\",\"secondColor\":\"#00324E\"},\"urn:rsi:topic:tv:80\":{\"firstColor\":\"#1F509D\",\"secondColor\":\"#121A37\"},\"urn:rsi:topic:tv:90\":{\"firstColor\":\"#8B96A5\",\"secondColor\":\"#4F5562\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:100\":{\"firstColor\":\"#D75959\",\"secondColor\":\"#29336C\"},\"urn:rsi:topic:tv:600\":{\"firstColor\":\"#23B6CD\",\"secondColor\":\"#7C3184\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:6000\":{\"firstColor\":\"#017EB3\",\"secondColor\":\"#011844\"},\"urn:rtr:topic:tv:2d48ba80-566c-4359-9e8d-8d9b2d570e0a\":{\"firstColor\":\"#00A1A1\",\"secondColor\":\"#04575B\"},\"urn:rtr:topic:tv:7d7f21be-6727-4939-9126-5bca25eb3a49\":{\"firstColor\":\"#80D2E3\",\"secondColor\":\"#003D58\"},\"urn:rtr:topic:tv:20e7478f-1ea1-49c3-81c2-5f157d6ff092\":{\"firstColor\":\"#340101\",\"secondColor\":\"#8F0E0F\"},\"urn:rtr:topic:tv:50bb90d6-41af-4bbd-b92c-6ef5db16a9b3\":{\"firstColor\":\"#8A0533\",\"secondColor\":\"#812626\"},\"urn:rtr:topic:tv:c50140e7-5740-4c44-abd0-0f7d9ea68da7\":{\"firstColor\":\"#A6A6A7\",\"secondColor\":\"#2C2B2D\"},\"urn:rtr:topic:tv:dfb7ae6d-cb73-431b-a817-b1663ec2f58a\":{\"firstColor\":\"#00F8CC\",\"secondColor\":\"#018864\"},\"urn:rts:topic:tv:623\":{\"firstColor\":\"#5C845B\",\"secondColor\":\"#16280F\"},\"urn:rts:topic:tv:665\":{\"firstColor\":\"#3787FF\",\"secondColor\":\"#0A1C33\"},\"urn:rts:topic:tv:1095\":{\"firstColor\":\"#F5F500\",\"secondColor\":\"#BEB405\",\"reduceBrightness\":true},\"urn:rts:topic:tv:1353\":{\"firstColor\":\"#084165\",\"secondColor\":\"#140953\"},\"urn:rts:topic:tv:2743\":{\"firstColor\":\"#BCF6FF\",\"secondColor\":\"#00D0EF\",\"reduceBrightness\":true},\"urn:rts:topic:tv:10193\":{\"firstColor\":\"#EB2350\",\"secondColor\":\"#A61637\"},\"urn:rts:topic:tv:54537\":{\"firstColor\":\"#FFE03E\",\"secondColor\":\"#F98E73\",\"reduceBrightness\":true},\"urn:rts:topic:tv:59220\":{\"firstColor\":\"#492b63\",\"secondColor\":\"#271633\"},\"urn:rts:topic:tv:67132\":{\"firstColor\":\"#415FAF\",\"secondColor\":\"#23376B\"},\"urn:srf:topic:tv:1d7d9cfb-6682-4d5b-9e36-322e8fa93c03\":{\"firstColor\":\"#00A4B3\",\"secondColor\":\"#006973\"},\"urn:srf:topic:tv:4acf86dd-7ff7-45d3-baf8-33375340d976\":{\"firstColor\":\"#3f4b70\",\"secondColor\":\"#131a2d\"},\"urn:srf:topic:tv:9a79b1de-cde8-4528-b304-d1ae1363f52f\":{\"firstColor\":\"#836fcd\",\"secondColor\":\"#36343f\"},\"urn:srf:topic:tv:63f937e4-859e-42c4-a430-bdb74dd09645\":{\"firstColor\":\"#4480a2\",\"secondColor\":\"#20182c\"},\"urn:srf:topic:tv:67f812fd-19a3-4c22-9e6b-ec36e65a4703\":{\"firstColor\":\"#bb3966\",\"secondColor\":\"#190406\"},\"urn:srf:topic:tv:593eb926-d892-41ba-8b1f-eccbcfd7f15f\":{\"firstColor\":\"#2bbf9b\",\"secondColor\":\"#02291e\"},\"urn:srf:topic:tv:649e36d7-ff57-41c8-9c1b-7892daf15e78\":{\"firstColor\":\"#FF0037\",\"secondColor\":\"#AF001E\"},\"urn:srf:topic:tv:882cb264-cf81-4a9c-b660-d42519b7ce28\":{\"firstColor\":\"#c91d7d\",\"secondColor\":\"#31041f\"},\"urn:srf:topic:tv:43741c59-317e-458b-ac38-c2b1c065c865\":{\"firstColor\":\"#0075ad\",\"secondColor\":\"#000022\"},\"urn:srf:topic:tv:516421f0-ec89-43ba-823b-1b5ceec262f3\":{\"firstColor\":\"#5FB281\",\"secondColor\":\"#154e60\"},\"urn:srf:topic:tv:641223fa-f112-4d98-8aec-cb22262a1182\":{\"firstColor\":\"#c55cee\",\"secondColor\":\"#0c1c68\"},\"urn:srf:topic:tv:a2d97206-0b85-4226-8afe-06e86ebd05b2\":{\"firstColor\":\"#9fc885\",\"secondColor\":\"#20281a\"},\"urn:srf:topic:tv:a709c610-b275-4c0c-a496-cba304c36712\":{\"firstColor\":\"#b3131d\",\"secondColor\":\"#3e0b14\"},\"urn:srf:topic:tv:b58dcf14-96ac-4046-8676-fd8a942c0e88\":{\"firstColor\":\"#7081b0\",\"secondColor\":\"#202020\"},\"urn:srf:topic:tv:bb7b21e0-1056-4e28-bac3-c610393b5b0f\":{\"firstColor\":\"#3c788e\",\"secondColor\":\"#1b3e48\"},\"urn:srf:topic:tv:e52080fc-f36b-481e-955f-071b6c8d6dc3\":{\"firstColor\":\"#ff6778\",\"secondColor\":\"#920a1a\",\"reduceBrightness\":true},\"urn:srf:topic:tv:fa793c13-bebc-41b9-9710-bf8a34192c15\":{\"firstColor\":\"#baead5\",\"secondColor\":\"#010b40\",\"reduceBrightness\":true}}", "continuousPlaybackPlayerViewTransitionDuration": 10, "continuousPlaybackForegroundTransitionDuration": 0, "continuousPlaybackBackgroundTransitionDuration": 0, - "endToleranceRatio": 0.02, + "endToleranceRatio": 0.07, "hiddenOnboardings": "account,favorites_account,resume_playback_account,watch_later_account", "discoverySubtitleOptionLanguage": "de", "audioDescriptionAvailabilityHidden": true, diff --git a/Application/Resources/Apps/Play RTR/rm.lproj/Localizable.strings b/Application/Resources/Apps/Play RTR/rm.lproj/Localizable.strings index 1e23768a3..90d61faa2 100755 --- a/Application/Resources/Apps/Play RTR/rm.lproj/Localizable.strings +++ b/Application/Resources/Apps/Play RTR/rm.lproj/Localizable.strings @@ -95,6 +95,10 @@ /* Audio description availability setting label */ "Audio description availability" = "Disponibilitad dad audio description"; +/* Audio home page selection view title + Label of the button for audio homepage option selection */ +"Audio home page" = "Padina da audio"; + /* Audios tab title Header for audio search results Search setting option @@ -159,7 +163,9 @@ /* Search setting */ "Date" = "Data"; -/* Poster images setting state */ +/* Audio homepage option setting state + Poster images setting state + Square images setting state */ "Default (current configuration)" = "Standard (configuraziun actuala)"; /* User location setting state */ @@ -270,7 +276,8 @@ /* Explanation displayed in the alert asking the user to enable notifications */ "For the application to inform you when a new episode is available, notifications must be enabled." = "Vegn utilisa da la applicaziun per infurmar vus cura che novas episodas stattan a disposiziun, la funcziunalitad da notificaziun sto esser activada."; -/* Poster images setting state */ +/* Poster images setting state + Square images setting state */ "Force" = "Sfurzar"; /* Total free space size displayed as a list footer */ @@ -299,7 +306,8 @@ /* Home tab title */ "Home" = "Home"; -/* Poster images setting state */ +/* Poster images setting state + Square images setting state */ "Ignore" = "Ignorar"; /* User location setting state */ @@ -381,6 +389,12 @@ /* Message title displayed when the user is forced to update the application. */ "Mandatory update" = "Dovra update"; +/* Many curated audio homepages option setting state */ +"Many curated pages (PAC landing pages)" = "Pliras paginas curatadas (PAC landing pages)"; + +/* Many predefined audio homepage option setting state */ +"Many predefined pages" = "Pliras paginas predefinadas"; + /* Message on top screen when trying to open a media in the download list and the media is not downloaded. */ "Media not available yet" = "Media betg disponibel"; @@ -473,6 +487,9 @@ Title of the search settings button to apply settings */ "OK" = "OK"; +/* One curated audio homepage option setting state */ +"One curated page (PAC Audio)" = "Ina pagina curatada (PAC audio)"; + /* Label of the button opening system settings */ "Open system settings" = "Avrir las preferenzas dal sistem"; @@ -661,6 +678,10 @@ Title label used to present sport scheduled livestream medias */ "Sport livestreams" = "RTR livestreams"; +/* Label of the button for Podcast square image format selection + Podcast square image format selection view title */ +"Square images" = "Maletgs quadratics"; + /* Server setting name */ "Stage" = "Stage"; diff --git a/Application/Resources/Apps/Play RTS/ApplicationConfiguration.json b/Application/Resources/Apps/Play RTS/ApplicationConfiguration.json index 53c478300..ab5040243 100755 --- a/Application/Resources/Apps/Play RTS/ApplicationConfiguration.json +++ b/Application/Resources/Apps/Play RTS/ApplicationConfiguration.json @@ -24,11 +24,11 @@ "radioChannels": "[{\"uid\":\"a9e7621504c6959e35c3ecbe7f6bed0446cdf8da\",\"name\":\"La 1ère\",\"resourceUid\":\"la1ere\",\"songsViewStyle\":\"collapsed\",\"color\":\"#E20026\",\"secondColor\":\"#5A285B\"},{\"uid\":\"a83f29dee7a5d0d3f9fccdb9c92161b1afb512db\",\"name\":\"Espace 2\",\"resourceUid\":\"espace2\",\"songsViewStyle\":\"collapsed\",\"color\":\"#0071CE\",\"secondColor\":\"#23B7C1\"},{\"uid\":\"8ceb28d9b3f1dd876d1df1780f908578cbefc3d7\",\"name\":\"Couleur 3\",\"resourceUid\":\"couleur3\",\"songsViewStyle\":\"collapsed\",\"color\":\"#E60096\",\"secondColor\":\"#FB5952\"},{\"uid\":\"f8517e5319a515e013551eea15aa114fa5cfbc3a\",\"name\":\"Option Musique\",\"resourceUid\":\"option_musique\",\"songsViewStyle\":\"expanded\",\"color\":\"#00CC99\",\"secondColor\":\"#CBC57A\"},{\"uid\":\"123456789101112131415161718192021222324x\",\"name\":\"Podcasts Originaux\",\"resourceUid\":\"podcasts_originaux\",\"color\":\"#A550F9\",\"homeSections\":\"radioLatestEpisodes,radioShowsAccess,radioFavoriteShows,radioLatestEpisodesFromFavorites,radioResumePlayback,radioMostPopular,radioWatchLater,radioAllShows\"}]", "tvChannels": "[{\"uid\":\"la1\",\"name\":\"LA 1\",\"resourceUid\":\"la1\",\"color\":\"#FF9120\",\"secondColor\":\"#E15100\"},{\"uid\":\"la2\",\"name\":\"LA 2\",\"resourceUid\":\"la2\",\"color\":\"#FFCF2F\",\"secondColor\":\"#F38A0D\"},{\"uid\":\"143932a79bb5a123a646b68b1d1188d7ae493e5b\",\"name\":\"RTS 1\",\"resourceUid\":\"rts_un\",\"color\":\"#00D6F3\",\"secondColor\":\"#00B6F0\",\"titleColor\":\"#161616\"},{\"uid\":\"d7dfff28deee44e1d3c49a3d37d36d492b29671b\",\"name\":\"RTS 2\",\"resourceUid\":\"rts_deux\",\"color\":\"#BB66FF\",\"secondColor\":\"#782EB5\"},{\"uid\":\"5d332a26e06d08eec8ad385d566187df72955623\",\"name\":\"RTS Info\",\"resourceUid\":\"rts_info\",\"color\":\"#3787FF\",\"secondColor\":\"#153567\"},{\"uid\":\"23FFBE1B-65CE-4188-ADD2-C724186C2C9F\",\"name\":\"SRF 1\",\"resourceUid\":\"tv_srf1\",\"color\":\"#C91024\",\"secondColor\":\"#8D0614\"},{\"uid\":\"E4D5AD08-C1E8-46A3-BB58-4875051D60D2\",\"name\":\"SRF zwei\",\"resourceUid\":\"tv_srf2\",\"color\":\"#FFB600\",\"secondColor\":\"#ED7004\",\"titleColor\":\"#161616\",\"hasDarkStatusBar\":true},{\"uid\":\"34c2819e-e715-43d7-9026-40a443152a97\",\"name\":\"SRF info\",\"resourceUid\":\"tv_srf_info\",\"color\":\"#AF001E\",\"secondColor\":\"#830512\"}]", "satelliteRadioChannels": "[{\"uid\":\"rsp\",\"name\":\"Radio Swiss Pop\",\"resourceUid\":\"rsp\",\"songsViewStyle\":\"expanded\",\"color\":\"#F01F73\",\"secondColor\":\"#D31A3C\",\"homepageHidden\":true, \"shareURL\":\"https://www.radioswisspop.ch/fr\"},{\"uid\":\"rsc-fr\",\"name\":\"Radio Swiss Classic\",\"resourceUid\":\"rsc\",\"songsViewStyle\":\"expanded\",\"color\":\"#09A1DE\",\"secondColor\":\"#036E99\",\"homepageHidden\":true, \"shareURL\":\"https://www.radioswissclassic.ch/fr\"},{\"uid\":\"rsj\",\"name\":\"Radio Swiss Jazz\",\"resourceUid\":\"rsj\",\"songsViewStyle\":\"expanded\",\"color\":\"#F7B222\",\"secondColor\":\"#CC7A00\",\"homepageHidden\":true, \"shareURL\":\"https://www.radioswissjazz.ch/fr\"}]", - "topicColors": "{\"urn:rsi:topic:tv:1\":{\"firstColor\":\"#c01232\",\"secondColor\":\"#480010\"},\"urn:rsi:topic:tv:4\":{\"firstColor\":\"#d7b447\",\"secondColor\":\"#b62019\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:7\":{\"firstColor\":\"#da2146\",\"secondColor\":\"#2d38c0\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:8\":{\"firstColor\":\"#cd4023\",\"secondColor\":\"#90062e\"},\"urn:rsi:topic:tv:11\":{\"firstColor\":\"#dea706\",\"secondColor\":\"#bd2e5e\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:40\":{\"firstColor\":\"#44bda8\",\"secondColor\":\"#00324e\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:80\":{\"firstColor\":\"#1f509d\",\"secondColor\":\"#121a37\"},\"urn:rsi:topic:tv:90\":{\"firstColor\":\"#738dae\",\"secondColor\":\"#3a465e\"},\"urn:rsi:topic:tv:100\":{\"firstColor\":\"#d75959\",\"secondColor\":\"#29336c\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:600\":{\"firstColor\":\"#27DCF9\",\"secondColor\":\"#932387\"},\"urn:rsi:topic:tv:6000\":{\"firstColor\":\"#02cde9\",\"secondColor\":\"#011844\"},\"urn:rtr:topic:tv:2d48ba80-566c-4359-9e8d-8d9b2d570e0a\":{\"firstColor\":\"#00A1A1\",\"secondColor\":\"#04575B\"},\"urn:rtr:topic:tv:7d7f21be-6727-4939-9126-5bca25eb3a49\":{\"firstColor\":\"#80D2E3\",\"secondColor\":\"#003D58\"},\"urn:rtr:topic:tv:20e7478f-1ea1-49c3-81c2-5f157d6ff092\":{\"firstColor\":\"#340101\",\"secondColor\":\"#8F0E0F\"},\"urn:rtr:topic:tv:50bb90d6-41af-4bbd-b92c-6ef5db16a9b3\":{\"firstColor\":\"#8A0533\",\"secondColor\":\"#812626\"},\"urn:rtr:topic:tv:c50140e7-5740-4c44-abd0-0f7d9ea68da7\":{\"firstColor\":\"#A6A6A7\",\"secondColor\":\"#2C2B2D\"},\"urn:rtr:topic:tv:dfb7ae6d-cb73-431b-a817-b1663ec2f58a\":{\"firstColor\":\"#00F8CC\",\"secondColor\":\"#018864\"},\"urn:rts:topic:tv:623\":{\"firstColor\":\"#5C845B\",\"secondColor\":\"#16280F\"},\"urn:rts:topic:tv:665\":{\"firstColor\":\"#3787FF\",\"secondColor\":\"#0A1C33\"},\"urn:rts:topic:tv:1095\":{\"firstColor\":\"#F5F500\",\"secondColor\":\"#BEB405\",\"reduceBrightness\":true},\"urn:rts:topic:tv:1353\":{\"firstColor\":\"#084165\",\"secondColor\":\"#140953\"},\"urn:rts:topic:tv:2743\":{\"firstColor\":\"#BCF6FF\",\"secondColor\":\"#00D0EF\",\"reduceBrightness\":true},\"urn:rts:topic:tv:10193\":{\"firstColor\":\"#EB2350\",\"secondColor\":\"#A61637\"},\"urn:rts:topic:tv:54537\":{\"firstColor\":\"#FFE03E\",\"secondColor\":\"#F98E73\",\"reduceBrightness\":true},\"urn:rts:topic:tv:59220\":{\"firstColor\":\"#492b63\",\"secondColor\":\"#271633\"},\"urn:rts:topic:tv:67132\":{\"firstColor\":\"#415FAF\",\"secondColor\":\"#23376B\"},\"urn:srf:topic:tv:1d7d9cfb-6682-4d5b-9e36-322e8fa93c03\":{\"firstColor\":\"#00A4B3\",\"secondColor\":\"#006973\"},\"urn:srf:topic:tv:4acf86dd-7ff7-45d3-baf8-33375340d976\":{\"firstColor\":\"#3f4b70\",\"secondColor\":\"#131a2d\"},\"urn:srf:topic:tv:9a79b1de-cde8-4528-b304-d1ae1363f52f\":{\"firstColor\":\"#836fcd\",\"secondColor\":\"#36343f\"},\"urn:srf:topic:tv:63f937e4-859e-42c4-a430-bdb74dd09645\":{\"firstColor\":\"#4480a2\",\"secondColor\":\"#20182c\"},\"urn:srf:topic:tv:67f812fd-19a3-4c22-9e6b-ec36e65a4703\":{\"firstColor\":\"#bb3966\",\"secondColor\":\"#190406\"},\"urn:srf:topic:tv:593eb926-d892-41ba-8b1f-eccbcfd7f15f\":{\"firstColor\":\"#2bbf9b\",\"secondColor\":\"#02291e\"},\"urn:srf:topic:tv:649e36d7-ff57-41c8-9c1b-7892daf15e78\":{\"firstColor\":\"#FF0037\",\"secondColor\":\"#AF001E\"},\"urn:srf:topic:tv:882cb264-cf81-4a9c-b660-d42519b7ce28\":{\"firstColor\":\"#c91d7d\",\"secondColor\":\"#31041f\"},\"urn:srf:topic:tv:43741c59-317e-458b-ac38-c2b1c065c865\":{\"firstColor\":\"#0075ad\",\"secondColor\":\"#000022\"},\"urn:srf:topic:tv:516421f0-ec89-43ba-823b-1b5ceec262f3\":{\"firstColor\":\"#5FB281\",\"secondColor\":\"#154e60\"},\"urn:srf:topic:tv:641223fa-f112-4d98-8aec-cb22262a1182\":{\"firstColor\":\"#c55cee\",\"secondColor\":\"#0c1c68\"},\"urn:srf:topic:tv:a2d97206-0b85-4226-8afe-06e86ebd05b2\":{\"firstColor\":\"#9fc885\",\"secondColor\":\"#20281a\"},\"urn:srf:topic:tv:a709c610-b275-4c0c-a496-cba304c36712\":{\"firstColor\":\"#b3131d\",\"secondColor\":\"#3e0b14\"},\"urn:srf:topic:tv:b58dcf14-96ac-4046-8676-fd8a942c0e88\":{\"firstColor\":\"#7081b0\",\"secondColor\":\"#202020\"},\"urn:srf:topic:tv:bb7b21e0-1056-4e28-bac3-c610393b5b0f\":{\"firstColor\":\"#3c788e\",\"secondColor\":\"#1b3e48\"},\"urn:srf:topic:tv:e52080fc-f36b-481e-955f-071b6c8d6dc3\":{\"firstColor\":\"#ff6778\",\"secondColor\":\"#920a1a\",\"reduceBrightness\":true},\"urn:srf:topic:tv:fa793c13-bebc-41b9-9710-bf8a34192c15\":{\"firstColor\":\"#baead5\",\"secondColor\":\"#010b40\",\"reduceBrightness\":true}}", + "topicColors": "{\"urn:rsi:topic:tv:1\":{\"firstColor\":\"#B5344E\",\"secondColor\":\"#480010\"},\"urn:rsi:topic:tv:4\":{\"firstColor\":\"#D7B447\",\"secondColor\":\"#B62019\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:7\":{\"firstColor\":\"#546591\",\"secondColor\":\"#2C3A50\"},\"urn:rsi:topic:tv:8\":{\"firstColor\":\"#CD4023\",\"secondColor\":\"#90062E\"},\"urn:rsi:topic:tv:11\":{\"firstColor\":\"#DEA706\",\"secondColor\":\"#E92466\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:40\":{\"firstColor\":\"#44BDA8\",\"secondColor\":\"#00324E\"},\"urn:rsi:topic:tv:80\":{\"firstColor\":\"#1F509D\",\"secondColor\":\"#121A37\"},\"urn:rsi:topic:tv:90\":{\"firstColor\":\"#8B96A5\",\"secondColor\":\"#4F5562\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:100\":{\"firstColor\":\"#D75959\",\"secondColor\":\"#29336C\"},\"urn:rsi:topic:tv:600\":{\"firstColor\":\"#23B6CD\",\"secondColor\":\"#7C3184\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:6000\":{\"firstColor\":\"#017EB3\",\"secondColor\":\"#011844\"},\"urn:rtr:topic:tv:2d48ba80-566c-4359-9e8d-8d9b2d570e0a\":{\"firstColor\":\"#00A1A1\",\"secondColor\":\"#04575B\"},\"urn:rtr:topic:tv:7d7f21be-6727-4939-9126-5bca25eb3a49\":{\"firstColor\":\"#80D2E3\",\"secondColor\":\"#003D58\"},\"urn:rtr:topic:tv:20e7478f-1ea1-49c3-81c2-5f157d6ff092\":{\"firstColor\":\"#340101\",\"secondColor\":\"#8F0E0F\"},\"urn:rtr:topic:tv:50bb90d6-41af-4bbd-b92c-6ef5db16a9b3\":{\"firstColor\":\"#8A0533\",\"secondColor\":\"#812626\"},\"urn:rtr:topic:tv:c50140e7-5740-4c44-abd0-0f7d9ea68da7\":{\"firstColor\":\"#A6A6A7\",\"secondColor\":\"#2C2B2D\"},\"urn:rtr:topic:tv:dfb7ae6d-cb73-431b-a817-b1663ec2f58a\":{\"firstColor\":\"#00F8CC\",\"secondColor\":\"#018864\"},\"urn:rts:topic:tv:623\":{\"firstColor\":\"#5C845B\",\"secondColor\":\"#16280F\"},\"urn:rts:topic:tv:665\":{\"firstColor\":\"#3787FF\",\"secondColor\":\"#0A1C33\"},\"urn:rts:topic:tv:1095\":{\"firstColor\":\"#F5F500\",\"secondColor\":\"#BEB405\",\"reduceBrightness\":true},\"urn:rts:topic:tv:1353\":{\"firstColor\":\"#084165\",\"secondColor\":\"#140953\"},\"urn:rts:topic:tv:2743\":{\"firstColor\":\"#BCF6FF\",\"secondColor\":\"#00D0EF\",\"reduceBrightness\":true},\"urn:rts:topic:tv:10193\":{\"firstColor\":\"#EB2350\",\"secondColor\":\"#A61637\"},\"urn:rts:topic:tv:54537\":{\"firstColor\":\"#FFE03E\",\"secondColor\":\"#F98E73\",\"reduceBrightness\":true},\"urn:rts:topic:tv:59220\":{\"firstColor\":\"#492b63\",\"secondColor\":\"#271633\"},\"urn:rts:topic:tv:67132\":{\"firstColor\":\"#415FAF\",\"secondColor\":\"#23376B\"},\"urn:srf:topic:tv:1d7d9cfb-6682-4d5b-9e36-322e8fa93c03\":{\"firstColor\":\"#00A4B3\",\"secondColor\":\"#006973\"},\"urn:srf:topic:tv:4acf86dd-7ff7-45d3-baf8-33375340d976\":{\"firstColor\":\"#3f4b70\",\"secondColor\":\"#131a2d\"},\"urn:srf:topic:tv:9a79b1de-cde8-4528-b304-d1ae1363f52f\":{\"firstColor\":\"#836fcd\",\"secondColor\":\"#36343f\"},\"urn:srf:topic:tv:63f937e4-859e-42c4-a430-bdb74dd09645\":{\"firstColor\":\"#4480a2\",\"secondColor\":\"#20182c\"},\"urn:srf:topic:tv:67f812fd-19a3-4c22-9e6b-ec36e65a4703\":{\"firstColor\":\"#bb3966\",\"secondColor\":\"#190406\"},\"urn:srf:topic:tv:593eb926-d892-41ba-8b1f-eccbcfd7f15f\":{\"firstColor\":\"#2bbf9b\",\"secondColor\":\"#02291e\"},\"urn:srf:topic:tv:649e36d7-ff57-41c8-9c1b-7892daf15e78\":{\"firstColor\":\"#FF0037\",\"secondColor\":\"#AF001E\"},\"urn:srf:topic:tv:882cb264-cf81-4a9c-b660-d42519b7ce28\":{\"firstColor\":\"#c91d7d\",\"secondColor\":\"#31041f\"},\"urn:srf:topic:tv:43741c59-317e-458b-ac38-c2b1c065c865\":{\"firstColor\":\"#0075ad\",\"secondColor\":\"#000022\"},\"urn:srf:topic:tv:516421f0-ec89-43ba-823b-1b5ceec262f3\":{\"firstColor\":\"#5FB281\",\"secondColor\":\"#154e60\"},\"urn:srf:topic:tv:641223fa-f112-4d98-8aec-cb22262a1182\":{\"firstColor\":\"#c55cee\",\"secondColor\":\"#0c1c68\"},\"urn:srf:topic:tv:a2d97206-0b85-4226-8afe-06e86ebd05b2\":{\"firstColor\":\"#9fc885\",\"secondColor\":\"#20281a\"},\"urn:srf:topic:tv:a709c610-b275-4c0c-a496-cba304c36712\":{\"firstColor\":\"#b3131d\",\"secondColor\":\"#3e0b14\"},\"urn:srf:topic:tv:b58dcf14-96ac-4046-8676-fd8a942c0e88\":{\"firstColor\":\"#7081b0\",\"secondColor\":\"#202020\"},\"urn:srf:topic:tv:bb7b21e0-1056-4e28-bac3-c610393b5b0f\":{\"firstColor\":\"#3c788e\",\"secondColor\":\"#1b3e48\"},\"urn:srf:topic:tv:e52080fc-f36b-481e-955f-071b6c8d6dc3\":{\"firstColor\":\"#ff6778\",\"secondColor\":\"#920a1a\",\"reduceBrightness\":true},\"urn:srf:topic:tv:fa793c13-bebc-41b9-9710-bf8a34192c15\":{\"firstColor\":\"#baead5\",\"secondColor\":\"#010b40\",\"reduceBrightness\":true}}", "continuousPlaybackPlayerViewTransitionDuration": 10, "continuousPlaybackForegroundTransitionDuration": 0, "continuousPlaybackBackgroundTransitionDuration": 0, - "endToleranceRatio": 0.02, + "endToleranceRatio": 0.07, "hiddenOnboardings": "favorites,resume_playback,watch_later", "showLeadPreferred": true, "tvGuideOtherBouquets": "srf,rsi", diff --git a/Application/Resources/Apps/Play RTS/fr.lproj/Localizable.strings b/Application/Resources/Apps/Play RTS/fr.lproj/Localizable.strings index 631783823..470c85a64 100644 --- a/Application/Resources/Apps/Play RTS/fr.lproj/Localizable.strings +++ b/Application/Resources/Apps/Play RTS/fr.lproj/Localizable.strings @@ -95,6 +95,10 @@ /* Audio description availability setting label */ "Audio description availability" = "Disponibilité de l'audiodescription"; +/* Audio home page selection view title + Label of the button for audio homepage option selection */ +"Audio home page" = "Page Audios"; + /* Audios tab title Header for audio search results Search setting option @@ -159,7 +163,9 @@ /* Search setting */ "Date" = "Date"; -/* Poster images setting state */ +/* Audio homepage option setting state + Poster images setting state + Square images setting state */ "Default (current configuration)" = "Défaut (configuration actuelle)"; /* User location setting state */ @@ -270,7 +276,8 @@ /* Explanation displayed in the alert asking the user to enable notifications */ "For the application to inform you when a new episode is available, notifications must be enabled." = "Pour que l'application puisse vous informer quand un nouvel épisode est disponible, les notifications doivent être activées."; -/* Poster images setting state */ +/* Poster images setting state + Square images setting state */ "Force" = "Forcer"; /* Total free space size displayed as a list footer */ @@ -299,7 +306,8 @@ /* Home tab title */ "Home" = "Accueil"; -/* Poster images setting state */ +/* Poster images setting state + Square images setting state */ "Ignore" = "Ignorer"; /* User location setting state */ @@ -381,6 +389,12 @@ /* Message title displayed when the user is forced to update the application. */ "Mandatory update" = "Mise à jour obligatoire"; +/* Many curated audio homepages option setting state */ +"Many curated pages (PAC landing pages)" = "Plusieurs pages éditorialisées (Pages d'accueil PAC)"; + +/* Many predefined audio homepage option setting state */ +"Many predefined pages" = "Plusieurs pages préfinies"; + /* Message on top screen when trying to open a media in the download list and the media is not downloaded. */ "Media not available yet" = "Contenu non disponible"; @@ -473,6 +487,9 @@ Title of the search settings button to apply settings */ "OK" = "OK"; +/* One curated audio homepage option setting state */ +"One curated page (PAC Audio)" = "Une page éditorialisée (PAC Audio)"; + /* Label of the button opening system settings */ "Open system settings" = "Ouvrir Réglages"; @@ -661,6 +678,10 @@ Title label used to present sport scheduled livestream medias */ "Sport livestreams" = "Sports en direct"; +/* Label of the button for Podcast square image format selection + Podcast square image format selection view title */ +"Square images" = "Square images"; + /* Server setting name */ "Stage" = "Stage"; diff --git a/Application/Resources/Apps/Play SRF/ApplicationConfiguration.json b/Application/Resources/Apps/Play SRF/ApplicationConfiguration.json index 51c611713..7f18c43aa 100755 --- a/Application/Resources/Apps/Play SRF/ApplicationConfiguration.json +++ b/Application/Resources/Apps/Play SRF/ApplicationConfiguration.json @@ -20,11 +20,11 @@ "radioChannels": "[{\"uid\":\"69e8ac16-4327-4af4-b873-fd5cd6e895a7\",\"name\":\"Radio SRF 1\",\"resourceUid\":\"srf1\",\"songsViewStyle\":\"collapsed\",\"color\":\"#F7A600\",\"secondColor\":\"#FFD651\",\"titleColor\":\"#161616\",\"hasDarkStatusBar\":true,\"numberOfLivePlaceholders\":8},{\"uid\":\"c8537421-c9c5-4461-9c9c-c15816458b46\",\"name\":\"Radio SRF 2 Kultur\",\"resourceUid\":\"srf2\",\"songsViewStyle\":\"collapsed\",\"color\":\"#CA3DAB\",\"secondColor\":\"#8C1D60\"},{\"uid\":\"dd0fa1ba-4ff6-4e1a-ab74-d7e49057d96f\",\"name\":\"Radio SRF 3\",\"resourceUid\":\"srf3\",\"songsViewStyle\":\"expanded\",\"color\":\"#464646\",\"secondColor\":\"#000000\"},{\"uid\":\"ee1fb348-2b6a-4958-9aac-ec6c87e190da\",\"name\":\"Radio SRF 4 News\",\"resourceUid\":\"srf4\",\"color\":\"#E31F2B\",\"secondColor\":\"#6A0B0C\"},{\"uid\":\"a9c5c070-8899-46c7-ac27-f04f1be902fd\",\"name\":\"Radio SRF Musikwelle\",\"resourceUid\":\"srf_musikwelle\",\"songsViewStyle\":\"expanded\",\"color\":\"#42A3F1\",\"secondColor\":\"#0066B0\"},{\"uid\":\"66815fe2-9008-4853-80a5-f9caaffdf3a9\",\"name\":\"Radio SRF Virus\",\"resourceUid\":\"virus\",\"songsViewStyle\":\"expanded\",\"color\":\"#A5FF00\",\"secondColor\":\"#BDFF44\",\"titleColor\":\"#161616\",\"hasDarkStatusBar\":true,\"homepageHidden\":true}]", "tvChannels": "[{\"uid\":\"la1\",\"name\":\"LA 1\",\"resourceUid\":\"la1\",\"color\":\"#FF9120\",\"secondColor\":\"#E15100\"},{\"uid\":\"la2\",\"name\":\"LA 2\",\"resourceUid\":\"la2\",\"color\":\"#FFCF2F\",\"secondColor\":\"#F38A0D\"},{\"uid\":\"143932a79bb5a123a646b68b1d1188d7ae493e5b\",\"name\":\"RTS 1\",\"resourceUid\":\"rts_un\",\"color\":\"#00D6F3\",\"secondColor\":\"#00B6F0\",\"titleColor\":\"#161616\"},{\"uid\":\"d7dfff28deee44e1d3c49a3d37d36d492b29671b\",\"name\":\"RTS 2\",\"resourceUid\":\"rts_deux\",\"color\":\"#BB66FF\",\"secondColor\":\"#782EB5\"},{\"uid\":\"5d332a26e06d08eec8ad385d566187df72955623\",\"name\":\"RTS Info\",\"resourceUid\":\"rts_info\",\"color\":\"#3787FF\",\"secondColor\":\"#153567\"},{\"uid\":\"23FFBE1B-65CE-4188-ADD2-C724186C2C9F\",\"name\":\"SRF 1\",\"resourceUid\":\"tv_srf1\",\"color\":\"#C91024\",\"secondColor\":\"#8D0614\"},{\"uid\":\"E4D5AD08-C1E8-46A3-BB58-4875051D60D2\",\"name\":\"SRF zwei\",\"resourceUid\":\"tv_srf2\",\"color\":\"#FFB600\",\"secondColor\":\"#ED7004\",\"titleColor\":\"#161616\",\"hasDarkStatusBar\":true},{\"uid\":\"34c2819e-e715-43d7-9026-40a443152a97\",\"name\":\"SRF info\",\"resourceUid\":\"tv_srf_info\",\"color\":\"#AF001E\",\"secondColor\":\"#830512\"}]", "satelliteRadioChannels": "[{\"uid\":\"rsp\",\"name\":\"Radio Swiss Pop\",\"resourceUid\":\"rsp\",\"songsViewStyle\":\"expanded\",\"color\":\"#F01F73\",\"secondColor\":\"#D31A3C\",\"homepageHidden\":true, \"shareURL\":\"https://www.radioswisspop.ch/de\"},{\"uid\":\"rsc-de\",\"name\":\"Radio Swiss Classic\",\"resourceUid\":\"rsc\",\"songsViewStyle\":\"expanded\",\"color\":\"#09A1DE\",\"secondColor\":\"#036E99\",\"homepageHidden\":true, \"shareURL\":\"https://www.radioswissclassic.ch/de\"},{\"uid\":\"rsj\",\"name\":\"Radio Swiss Jazz\",\"resourceUid\":\"rsj\",\"songsViewStyle\":\"expanded\",\"color\":\"#F7B222\",\"secondColor\":\"#CC7A00\",\"homepageHidden\":true, \"shareURL\":\"https://www.radioswissjazz.ch/de\"}]", - "topicColors": "{\"urn:rsi:topic:tv:1\":{\"firstColor\":\"#c01232\",\"secondColor\":\"#480010\"},\"urn:rsi:topic:tv:4\":{\"firstColor\":\"#d7b447\",\"secondColor\":\"#b62019\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:7\":{\"firstColor\":\"#da2146\",\"secondColor\":\"#2d38c0\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:8\":{\"firstColor\":\"#cd4023\",\"secondColor\":\"#90062e\"},\"urn:rsi:topic:tv:11\":{\"firstColor\":\"#dea706\",\"secondColor\":\"#bd2e5e\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:40\":{\"firstColor\":\"#44bda8\",\"secondColor\":\"#00324e\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:80\":{\"firstColor\":\"#1f509d\",\"secondColor\":\"#121a37\"},\"urn:rsi:topic:tv:90\":{\"firstColor\":\"#738dae\",\"secondColor\":\"#3a465e\"},\"urn:rsi:topic:tv:100\":{\"firstColor\":\"#d75959\",\"secondColor\":\"#29336c\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:600\":{\"firstColor\":\"#27DCF9\",\"secondColor\":\"#932387\"},\"urn:rsi:topic:tv:6000\":{\"firstColor\":\"#02cde9\",\"secondColor\":\"#011844\"},\"urn:rtr:topic:tv:2d48ba80-566c-4359-9e8d-8d9b2d570e0a\":{\"firstColor\":\"#00A1A1\",\"secondColor\":\"#04575B\"},\"urn:rtr:topic:tv:7d7f21be-6727-4939-9126-5bca25eb3a49\":{\"firstColor\":\"#80D2E3\",\"secondColor\":\"#003D58\"},\"urn:rtr:topic:tv:20e7478f-1ea1-49c3-81c2-5f157d6ff092\":{\"firstColor\":\"#340101\",\"secondColor\":\"#8F0E0F\"},\"urn:rtr:topic:tv:50bb90d6-41af-4bbd-b92c-6ef5db16a9b3\":{\"firstColor\":\"#8A0533\",\"secondColor\":\"#812626\"},\"urn:rtr:topic:tv:c50140e7-5740-4c44-abd0-0f7d9ea68da7\":{\"firstColor\":\"#A6A6A7\",\"secondColor\":\"#2C2B2D\"},\"urn:rtr:topic:tv:dfb7ae6d-cb73-431b-a817-b1663ec2f58a\":{\"firstColor\":\"#00F8CC\",\"secondColor\":\"#018864\"},\"urn:rts:topic:tv:623\":{\"firstColor\":\"#5C845B\",\"secondColor\":\"#16280F\"},\"urn:rts:topic:tv:665\":{\"firstColor\":\"#3787FF\",\"secondColor\":\"#0A1C33\"},\"urn:rts:topic:tv:1095\":{\"firstColor\":\"#F5F500\",\"secondColor\":\"#BEB405\",\"reduceBrightness\":true},\"urn:rts:topic:tv:1353\":{\"firstColor\":\"#084165\",\"secondColor\":\"#140953\"},\"urn:rts:topic:tv:2743\":{\"firstColor\":\"#BCF6FF\",\"secondColor\":\"#00D0EF\",\"reduceBrightness\":true},\"urn:rts:topic:tv:10193\":{\"firstColor\":\"#EB2350\",\"secondColor\":\"#A61637\"},\"urn:rts:topic:tv:54537\":{\"firstColor\":\"#FFE03E\",\"secondColor\":\"#F98E73\",\"reduceBrightness\":true},\"urn:rts:topic:tv:59220\":{\"firstColor\":\"#492b63\",\"secondColor\":\"#271633\"},\"urn:rts:topic:tv:67132\":{\"firstColor\":\"#415FAF\",\"secondColor\":\"#23376B\"},\"urn:srf:topic:tv:1d7d9cfb-6682-4d5b-9e36-322e8fa93c03\":{\"firstColor\":\"#00A4B3\",\"secondColor\":\"#006973\"},\"urn:srf:topic:tv:4acf86dd-7ff7-45d3-baf8-33375340d976\":{\"firstColor\":\"#3f4b70\",\"secondColor\":\"#131a2d\"},\"urn:srf:topic:tv:9a79b1de-cde8-4528-b304-d1ae1363f52f\":{\"firstColor\":\"#836fcd\",\"secondColor\":\"#36343f\"},\"urn:srf:topic:tv:63f937e4-859e-42c4-a430-bdb74dd09645\":{\"firstColor\":\"#4480a2\",\"secondColor\":\"#20182c\"},\"urn:srf:topic:tv:67f812fd-19a3-4c22-9e6b-ec36e65a4703\":{\"firstColor\":\"#bb3966\",\"secondColor\":\"#190406\"},\"urn:srf:topic:tv:593eb926-d892-41ba-8b1f-eccbcfd7f15f\":{\"firstColor\":\"#2bbf9b\",\"secondColor\":\"#02291e\"},\"urn:srf:topic:tv:649e36d7-ff57-41c8-9c1b-7892daf15e78\":{\"firstColor\":\"#FF0037\",\"secondColor\":\"#AF001E\"},\"urn:srf:topic:tv:882cb264-cf81-4a9c-b660-d42519b7ce28\":{\"firstColor\":\"#c91d7d\",\"secondColor\":\"#31041f\"},\"urn:srf:topic:tv:43741c59-317e-458b-ac38-c2b1c065c865\":{\"firstColor\":\"#0075ad\",\"secondColor\":\"#000022\"},\"urn:srf:topic:tv:516421f0-ec89-43ba-823b-1b5ceec262f3\":{\"firstColor\":\"#5FB281\",\"secondColor\":\"#154e60\"},\"urn:srf:topic:tv:641223fa-f112-4d98-8aec-cb22262a1182\":{\"firstColor\":\"#c55cee\",\"secondColor\":\"#0c1c68\"},\"urn:srf:topic:tv:a2d97206-0b85-4226-8afe-06e86ebd05b2\":{\"firstColor\":\"#9fc885\",\"secondColor\":\"#20281a\"},\"urn:srf:topic:tv:a709c610-b275-4c0c-a496-cba304c36712\":{\"firstColor\":\"#b3131d\",\"secondColor\":\"#3e0b14\"},\"urn:srf:topic:tv:b58dcf14-96ac-4046-8676-fd8a942c0e88\":{\"firstColor\":\"#7081b0\",\"secondColor\":\"#202020\"},\"urn:srf:topic:tv:bb7b21e0-1056-4e28-bac3-c610393b5b0f\":{\"firstColor\":\"#3c788e\",\"secondColor\":\"#1b3e48\"},\"urn:srf:topic:tv:e52080fc-f36b-481e-955f-071b6c8d6dc3\":{\"firstColor\":\"#ff6778\",\"secondColor\":\"#920a1a\",\"reduceBrightness\":true},\"urn:srf:topic:tv:fa793c13-bebc-41b9-9710-bf8a34192c15\":{\"firstColor\":\"#baead5\",\"secondColor\":\"#010b40\",\"reduceBrightness\":true}}", + "topicColors": "{\"urn:rsi:topic:tv:1\":{\"firstColor\":\"#B5344E\",\"secondColor\":\"#480010\"},\"urn:rsi:topic:tv:4\":{\"firstColor\":\"#D7B447\",\"secondColor\":\"#B62019\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:7\":{\"firstColor\":\"#546591\",\"secondColor\":\"#2C3A50\"},\"urn:rsi:topic:tv:8\":{\"firstColor\":\"#CD4023\",\"secondColor\":\"#90062E\"},\"urn:rsi:topic:tv:11\":{\"firstColor\":\"#DEA706\",\"secondColor\":\"#E92466\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:40\":{\"firstColor\":\"#44BDA8\",\"secondColor\":\"#00324E\"},\"urn:rsi:topic:tv:80\":{\"firstColor\":\"#1F509D\",\"secondColor\":\"#121A37\"},\"urn:rsi:topic:tv:90\":{\"firstColor\":\"#8B96A5\",\"secondColor\":\"#4F5562\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:100\":{\"firstColor\":\"#D75959\",\"secondColor\":\"#29336C\"},\"urn:rsi:topic:tv:600\":{\"firstColor\":\"#23B6CD\",\"secondColor\":\"#7C3184\",\"reduceBrightness\":true},\"urn:rsi:topic:tv:6000\":{\"firstColor\":\"#017EB3\",\"secondColor\":\"#011844\"},\"urn:rtr:topic:tv:2d48ba80-566c-4359-9e8d-8d9b2d570e0a\":{\"firstColor\":\"#00A1A1\",\"secondColor\":\"#04575B\"},\"urn:rtr:topic:tv:7d7f21be-6727-4939-9126-5bca25eb3a49\":{\"firstColor\":\"#80D2E3\",\"secondColor\":\"#003D58\"},\"urn:rtr:topic:tv:20e7478f-1ea1-49c3-81c2-5f157d6ff092\":{\"firstColor\":\"#340101\",\"secondColor\":\"#8F0E0F\"},\"urn:rtr:topic:tv:50bb90d6-41af-4bbd-b92c-6ef5db16a9b3\":{\"firstColor\":\"#8A0533\",\"secondColor\":\"#812626\"},\"urn:rtr:topic:tv:c50140e7-5740-4c44-abd0-0f7d9ea68da7\":{\"firstColor\":\"#A6A6A7\",\"secondColor\":\"#2C2B2D\"},\"urn:rtr:topic:tv:dfb7ae6d-cb73-431b-a817-b1663ec2f58a\":{\"firstColor\":\"#00F8CC\",\"secondColor\":\"#018864\"},\"urn:rts:topic:tv:623\":{\"firstColor\":\"#5C845B\",\"secondColor\":\"#16280F\"},\"urn:rts:topic:tv:665\":{\"firstColor\":\"#3787FF\",\"secondColor\":\"#0A1C33\"},\"urn:rts:topic:tv:1095\":{\"firstColor\":\"#F5F500\",\"secondColor\":\"#BEB405\",\"reduceBrightness\":true},\"urn:rts:topic:tv:1353\":{\"firstColor\":\"#084165\",\"secondColor\":\"#140953\"},\"urn:rts:topic:tv:2743\":{\"firstColor\":\"#BCF6FF\",\"secondColor\":\"#00D0EF\",\"reduceBrightness\":true},\"urn:rts:topic:tv:10193\":{\"firstColor\":\"#EB2350\",\"secondColor\":\"#A61637\"},\"urn:rts:topic:tv:54537\":{\"firstColor\":\"#FFE03E\",\"secondColor\":\"#F98E73\",\"reduceBrightness\":true},\"urn:rts:topic:tv:59220\":{\"firstColor\":\"#492b63\",\"secondColor\":\"#271633\"},\"urn:rts:topic:tv:67132\":{\"firstColor\":\"#415FAF\",\"secondColor\":\"#23376B\"},\"urn:srf:topic:tv:1d7d9cfb-6682-4d5b-9e36-322e8fa93c03\":{\"firstColor\":\"#00A4B3\",\"secondColor\":\"#006973\"},\"urn:srf:topic:tv:4acf86dd-7ff7-45d3-baf8-33375340d976\":{\"firstColor\":\"#3f4b70\",\"secondColor\":\"#131a2d\"},\"urn:srf:topic:tv:9a79b1de-cde8-4528-b304-d1ae1363f52f\":{\"firstColor\":\"#836fcd\",\"secondColor\":\"#36343f\"},\"urn:srf:topic:tv:63f937e4-859e-42c4-a430-bdb74dd09645\":{\"firstColor\":\"#4480a2\",\"secondColor\":\"#20182c\"},\"urn:srf:topic:tv:67f812fd-19a3-4c22-9e6b-ec36e65a4703\":{\"firstColor\":\"#bb3966\",\"secondColor\":\"#190406\"},\"urn:srf:topic:tv:593eb926-d892-41ba-8b1f-eccbcfd7f15f\":{\"firstColor\":\"#2bbf9b\",\"secondColor\":\"#02291e\"},\"urn:srf:topic:tv:649e36d7-ff57-41c8-9c1b-7892daf15e78\":{\"firstColor\":\"#FF0037\",\"secondColor\":\"#AF001E\"},\"urn:srf:topic:tv:882cb264-cf81-4a9c-b660-d42519b7ce28\":{\"firstColor\":\"#c91d7d\",\"secondColor\":\"#31041f\"},\"urn:srf:topic:tv:43741c59-317e-458b-ac38-c2b1c065c865\":{\"firstColor\":\"#0075ad\",\"secondColor\":\"#000022\"},\"urn:srf:topic:tv:516421f0-ec89-43ba-823b-1b5ceec262f3\":{\"firstColor\":\"#5FB281\",\"secondColor\":\"#154e60\"},\"urn:srf:topic:tv:641223fa-f112-4d98-8aec-cb22262a1182\":{\"firstColor\":\"#c55cee\",\"secondColor\":\"#0c1c68\"},\"urn:srf:topic:tv:a2d97206-0b85-4226-8afe-06e86ebd05b2\":{\"firstColor\":\"#9fc885\",\"secondColor\":\"#20281a\"},\"urn:srf:topic:tv:a709c610-b275-4c0c-a496-cba304c36712\":{\"firstColor\":\"#b3131d\",\"secondColor\":\"#3e0b14\"},\"urn:srf:topic:tv:b58dcf14-96ac-4046-8676-fd8a942c0e88\":{\"firstColor\":\"#7081b0\",\"secondColor\":\"#202020\"},\"urn:srf:topic:tv:bb7b21e0-1056-4e28-bac3-c610393b5b0f\":{\"firstColor\":\"#3c788e\",\"secondColor\":\"#1b3e48\"},\"urn:srf:topic:tv:e52080fc-f36b-481e-955f-071b6c8d6dc3\":{\"firstColor\":\"#ff6778\",\"secondColor\":\"#920a1a\",\"reduceBrightness\":true},\"urn:srf:topic:tv:fa793c13-bebc-41b9-9710-bf8a34192c15\":{\"firstColor\":\"#baead5\",\"secondColor\":\"#010b40\",\"reduceBrightness\":true}}", "continuousPlaybackPlayerViewTransitionDuration": 10, "continuousPlaybackForegroundTransitionDuration": 0, "continuousPlaybackBackgroundTransitionDuration": 0, - "endToleranceRatio": 0.02, + "endToleranceRatio": 0.07, "hiddenOnboardings": "account,favorites_account,resume_playback_account,watch_later_account", "audioDescriptionAvailabilityHidden": true, "posterImagesEnabled": true, diff --git a/Application/Resources/Apps/Play SRF/de.lproj/Localizable.strings b/Application/Resources/Apps/Play SRF/de.lproj/Localizable.strings index 5e929500e..de13187aa 100755 --- a/Application/Resources/Apps/Play SRF/de.lproj/Localizable.strings +++ b/Application/Resources/Apps/Play SRF/de.lproj/Localizable.strings @@ -95,6 +95,10 @@ /* Audio description availability setting label */ "Audio description availability" = "Audiodeskription verfügbar"; +/* Audio home page selection view title + Label of the button for audio homepage option selection */ +"Audio home page" = "Audio home page"; + /* Audios tab title Header for audio search results Search setting option @@ -159,7 +163,9 @@ /* Search setting */ "Date" = "Datum"; -/* Poster images setting state */ +/* Audio homepage option setting state + Poster images setting state + Square images setting state */ "Default (current configuration)" = "Standard (aktuelle Konfiguration)"; /* User location setting state */ @@ -270,7 +276,8 @@ /* Explanation displayed in the alert asking the user to enable notifications */ "For the application to inform you when a new episode is available, notifications must be enabled." = "Damit Play SRF Sie über neue Episoden informieren kann, müssen Push-Mitteilungen aktiviert werden."; -/* Poster images setting state */ +/* Poster images setting state + Square images setting state */ "Force" = "Erzwingen"; /* Total free space size displayed as a list footer */ @@ -299,7 +306,8 @@ /* Home tab title */ "Home" = "Home"; -/* Poster images setting state */ +/* Poster images setting state + Square images setting state */ "Ignore" = "Ignorieren"; /* User location setting state */ @@ -381,6 +389,12 @@ /* Message title displayed when the user is forced to update the application. */ "Mandatory update" = "Update erforderlich"; +/* Many curated audio homepages option setting state */ +"Many curated pages (PAC landing pages)" = "Many curated pages (PAC landing pages)"; + +/* Many predefined audio homepage option setting state */ +"Many predefined pages" = "Many predefined pages"; + /* Message on top screen when trying to open a media in the download list and the media is not downloaded. */ "Media not available yet" = "Inhalt noch nicht verfügbar"; @@ -473,6 +487,9 @@ Title of the search settings button to apply settings */ "OK" = "OK"; +/* One curated audio homepage option setting state */ +"One curated page (PAC Audio)" = "One curated page (PAC Audio)"; + /* Label of the button opening system settings */ "Open system settings" = "Einstellungen anzeigen"; @@ -661,6 +678,10 @@ Title label used to present sport scheduled livestream medias */ "Sport livestreams" = "Sport-Livestreams"; +/* Label of the button for Podcast square image format selection + Podcast square image format selection view title */ +"Square images" = "Square images"; + /* Server setting name */ "Stage" = "Stage"; diff --git a/Application/Resources/Apps/Play SWI/ApplicationConfiguration.json b/Application/Resources/Apps/Play SWI/ApplicationConfiguration.json index e5a499552..5612d9e11 100755 --- a/Application/Resources/Apps/Play SWI/ApplicationConfiguration.json +++ b/Application/Resources/Apps/Play SWI/ApplicationConfiguration.json @@ -20,7 +20,7 @@ "continuousPlaybackPlayerViewTransitionDuration": 10, "continuousPlaybackForegroundTransitionDuration": 0, "continuousPlaybackBackgroundTransitionDuration": 0, - "endToleranceRatio": 0.02, + "endToleranceRatio": 0.07, "searchSettingsHidden": true, "showsSearchHidden": true, "hiddenOnboardings": "account,favorites_account,resume_playback_account,watch_later_account", diff --git a/Application/Resources/Apps/Play SWI/en.lproj/Localizable.strings b/Application/Resources/Apps/Play SWI/en.lproj/Localizable.strings index fa67e5be8..6eddef361 100755 --- a/Application/Resources/Apps/Play SWI/en.lproj/Localizable.strings +++ b/Application/Resources/Apps/Play SWI/en.lproj/Localizable.strings @@ -95,6 +95,10 @@ /* Audio description availability setting label */ "Audio description availability" = "Audio description availability"; +/* Audio home page selection view title + Label of the button for audio homepage option selection */ +"Audio home page" = "Audio home page"; + /* Audios tab title Header for audio search results Search setting option @@ -159,7 +163,9 @@ /* Search setting */ "Date" = "Date"; -/* Poster images setting state */ +/* Audio homepage option setting state + Poster images setting state + Square images setting state */ "Default (current configuration)" = "Default (current configuration)"; /* User location setting state */ @@ -270,7 +276,8 @@ /* Explanation displayed in the alert asking the user to enable notifications */ "For the application to inform you when a new episode is available, notifications must be enabled." = "For the application to inform you when a new episode is available, notifications must be enabled."; -/* Poster images setting state */ +/* Poster images setting state + Square images setting state */ "Force" = "Force"; /* Total free space size displayed as a list footer */ @@ -299,7 +306,8 @@ /* Home tab title */ "Home" = "Home"; -/* Poster images setting state */ +/* Poster images setting state + Square images setting state */ "Ignore" = "Ignore"; /* User location setting state */ @@ -381,6 +389,12 @@ /* Message title displayed when the user is forced to update the application. */ "Mandatory update" = "Mandatory update"; +/* Many curated audio homepages option setting state */ +"Many curated pages (PAC landing pages)" = "Many curated pages (PAC landing pages)"; + +/* Many predefined audio homepage option setting state */ +"Many predefined pages" = "Many predefined pages"; + /* Message on top screen when trying to open a media in the download list and the media is not downloaded. */ "Media not available yet" = "Media not available yet"; @@ -473,6 +487,9 @@ Title of the search settings button to apply settings */ "OK" = "OK"; +/* One curated audio homepage option setting state */ +"One curated page (PAC Audio)" = "One curated page (PAC Audio)"; + /* Label of the button opening system settings */ "Open system settings" = "Open system settings"; @@ -661,6 +678,10 @@ Title label used to present sport scheduled livestream medias */ "Sport livestreams" = "Sport livestreams"; +/* Label of the button for Podcast square image format selection + Podcast square image format selection view title */ +"Square images" = "Square images"; + /* Server setting name */ "Stage" = "Stage"; diff --git a/Application/Resources/Settings.bundle/com.mono0926.LicensePlist.latest_result.txt b/Application/Resources/Settings.bundle/com.mono0926.LicensePlist.latest_result.txt index d936d543d..5ddb3b792 100755 --- a/Application/Resources/Settings.bundle/com.mono0926.LicensePlist.latest_result.txt +++ b/Application/Resources/Settings.bundle/com.mono0926.LicensePlist.latest_result.txt @@ -28,94 +28,19 @@ body: AutoCoding Copyrigh… version: 2.2.3 -name: MaterialComponents, nameSpecified: -body: - … -version: 118.2.0 - -name: MaterialComponents, nameSpecified: -body: - … -version: 118.2.0 - -name: MaterialComponents, nameSpecified: -body: - … -version: 118.2.0 - -name: MaterialComponents, nameSpecified: -body: - … -version: 118.2.0 - -name: MaterialComponents, nameSpecified: -body: - … -version: 118.2.0 - -name: MDFInternationalization, nameSpecified: -body: - … -version: 3.0.0 - -name: MDFInternationalization, nameSpecified: -body: - … -version: 3.0.0 - -name: MDFInternationalization, nameSpecified: -body: - … -version: 3.0.0 - -name: MDFInternationalization, nameSpecified: -body: - … -version: 3.0.0 - -name: MDFInternationalization, nameSpecified: -body: - … -version: 3.0.0 - -name: MDFTextAccessibility, nameSpecified: -body: - … -version: 2.0.1 - -name: MDFTextAccessibility, nameSpecified: -body: - … -version: 2.0.1 - -name: MDFTextAccessibility, nameSpecified: -body: - … -version: 2.0.1 - -name: MDFTextAccessibility, nameSpecified: -body: - … -version: 2.0.1 - -name: MDFTextAccessibility, nameSpecified: -body: - … -version: 2.0.1 - -name: abseil-cpp-binary, nameSpecified: abseil, owner: google, version: 1.2024011601.0, source: https://github.com/google/abseil-cpp-binary +name: abseil-cpp-binary, nameSpecified: abseil, owner: google, version: 1.2024011601.1, source: https://github.com/google/abseil-cpp-binary name: Aiolos, nameSpecified: Aiolos, owner: IdeasOnCanvas, version: 1.9.8, source: https://github.com/IdeasOnCanvas/Aiolos -name: app-check, nameSpecified: AppCheck, owner: google, version: 10.18.1, source: https://github.com/google/app-check +name: app-check, nameSpecified: AppCheck, owner: google, version: 10.19.1, source: https://github.com/google/app-check -name: appcenter-sdk-apple, nameSpecified: AppCenter, owner: microsoft, version: 5.0.4, source: https://github.com/microsoft/appcenter-sdk-apple +name: appcenter-sdk-apple, nameSpecified: AppCenter, owner: microsoft, version: 5.0.5, source: https://github.com/microsoft/appcenter-sdk-apple name: Comscore-Swift-Package-Manager, nameSpecified: Comscore-Swift-Package-Manager, owner: comScore, version: 6.11.0, source: https://github.com/comScore/Comscore-Swift-Package-Manager name: DZNEmptyDataSet, nameSpecified: DZNEmptyDataSet, owner: dzenbot, version: , source: https://github.com/dzenbot/DZNEmptyDataSet -name: firebase-ios-sdk, nameSpecified: Firebase, owner: firebase, version: 10.23.1, source: https://github.com/firebase/firebase-ios-sdk +name: firebase-ios-sdk, nameSpecified: Firebase, owner: firebase, version: 10.27.0, source: https://github.com/firebase/firebase-ios-sdk name: FSCalendar, nameSpecified: FSCalendar, owner: WenchaoD, version: 2.8.4, source: https://github.com/WenchaoD/FSCalendar @@ -123,25 +48,25 @@ name: FXReachability, nameSpecified: FXReachability, owner: SRGSSR, version: 1.3 name: Gifu, nameSpecified: Gifu, owner: kaishin, version: 3.4.1, source: https://github.com/kaishin/Gifu -name: GoogleAppMeasurement, nameSpecified: GoogleAppMeasurement, owner: google, version: 10.23.1, source: https://github.com/google/GoogleAppMeasurement +name: GoogleAppMeasurement, nameSpecified: GoogleAppMeasurement, owner: google, version: 10.27.0, source: https://github.com/google/GoogleAppMeasurement name: GoogleCastSDK-no-bluetooth, nameSpecified: GoogleCastSDK-no-bluetooth, owner: SRGSSR, version: 4.8.0, source: https://github.com/SRGSSR/GoogleCastSDK-no-bluetooth name: GoogleDataTransport, nameSpecified: GoogleDataTransport, owner: google, version: 9.4.0, source: https://github.com/google/GoogleDataTransport -name: GoogleUtilities, nameSpecified: GoogleUtilities, owner: google, version: 7.13.1, source: https://github.com/google/GoogleUtilities +name: GoogleUtilities, nameSpecified: GoogleUtilities, owner: google, version: 7.13.3, source: https://github.com/google/GoogleUtilities -name: grpc-binary, nameSpecified: gRPC, owner: google, version: 1.62.1, source: https://github.com/google/grpc-binary +name: grpc-binary, nameSpecified: gRPC, owner: google, version: 1.62.2, source: https://github.com/google/grpc-binary -name: gtm-session-fetcher, nameSpecified: gtm-session-fetcher, owner: google, version: 3.3.2, source: https://github.com/google/gtm-session-fetcher +name: gtm-session-fetcher, nameSpecified: gtm-session-fetcher, owner: google, version: 3.4.1, source: https://github.com/google/gtm-session-fetcher name: interop-ios-for-google-sdks, nameSpecified: InteropForGoogle, owner: google, version: 100.0.0, source: https://github.com/google/interop-ios-for-google-sdks -name: ios-library, nameSpecified: Airship, owner: urbanairship, version: 16.12.6, source: https://github.com/urbanairship/ios-library +name: ios-library, nameSpecified: Airship, owner: urbanairship, version: 16.12.7, source: https://github.com/urbanairship/ios-library -name: iOSV5, nameSpecified: TagCommander SDK V5, owner: CommandersAct, version: 5.4.6, source: https://github.com/CommandersAct/iOSV5 +name: iOSV5, nameSpecified: TagCommander SDK V5, owner: CommandersAct, version: 5.4.9, source: https://github.com/CommandersAct/iOSV5 -name: leveldb, nameSpecified: leveldb, owner: firebase, version: 1.22.4, source: https://github.com/firebase/leveldb +name: leveldb, nameSpecified: leveldb, owner: firebase, version: 1.22.5, source: https://github.com/firebase/leveldb name: libextobjc, nameSpecified: libextobjc, owner: SRGSSR, version: 0.6.0-srg4, source: https://github.com/SRGSSR/libextobjc @@ -155,9 +80,11 @@ name: Nuke, nameSpecified: Nuke, owner: kean, version: 10.11.2, source: https:// name: NukeUI, nameSpecified: NukeUI, owner: kean, version: 0.8.3, source: https://github.com/kean/NukeUI +name: Pageboy, nameSpecified: Pageboy, owner: uias, version: 4.2.0, source: https://github.com/uias/Pageboy + name: paper-onboarding, nameSpecified: PaperOnboarding, owner: Ramotion, version: 6.1.5, source: https://github.com/Ramotion/paper-onboarding -name: PLCrashReporter, nameSpecified: PLCrashReporter, owner: microsoft, version: 1.11.1, source: https://github.com/microsoft/PLCrashReporter +name: PLCrashReporter, nameSpecified: PLCrashReporter, owner: microsoft, version: 1.11.2, source: https://github.com/microsoft/PLCrashReporter name: promises, nameSpecified: Promises, owner: google, version: 2.4.0, source: https://github.com/google/promises @@ -167,13 +94,13 @@ name: srgappearance-apple, nameSpecified: SRGAppearance, owner: SRGSSR, version: name: srgcontentprotection-apple, nameSpecified: SRGContentProtection, owner: SRGSSR, version: 3.1.0, source: https://github.com/SRGSSR/srgcontentprotection-apple -name: srgdataprovider-apple, nameSpecified: SRGDataProvider, owner: SRGSSR, version: 19.0.3, source: https://github.com/SRGSSR/srgdataprovider-apple +name: srgdataprovider-apple, nameSpecified: SRGDataProvider, owner: SRGSSR, version: 19.0.4, source: https://github.com/SRGSSR/srgdataprovider-apple name: srgdiagnostics-apple, nameSpecified: SRGDiagnostics, owner: SRGSSR, version: 3.1.0, source: https://github.com/SRGSSR/srgdiagnostics-apple name: srgidentity-apple, nameSpecified: SRGIdentity, owner: SRGSSR, version: 3.3.0, source: https://github.com/SRGSSR/srgidentity-apple -name: srgletterbox-apple, nameSpecified: SRGLetterbox, owner: SRGSSR, version: 9.3.0, source: https://github.com/SRGSSR/srgletterbox-apple +name: srgletterbox-apple, nameSpecified: SRGLetterbox, owner: SRGSSR, version: 9.3.1, source: https://github.com/SRGSSR/srgletterbox-apple name: srglogger-apple, nameSpecified: SRGLogger, owner: SRGSSR, version: 3.1.0, source: https://github.com/SRGSSR/srglogger-apple @@ -183,7 +110,7 @@ name: srgnetwork-apple, nameSpecified: SRGNetwork, owner: SRGSSR, version: 3.1.0 name: srguserdata-apple, nameSpecified: SRGUserData, owner: SRGSSR, version: 3.3.1, source: https://github.com/SRGSSR/srguserdata-apple -name: swift-collections, nameSpecified: swift-collections, owner: apple, version: 1.1.0, source: https://github.com/apple/swift-collections +name: swift-collections, nameSpecified: swift-collections, owner: apple, version: 1.1.1, source: https://github.com/apple/swift-collections name: swift-protobuf, nameSpecified: SwiftProtobuf, owner: apple, version: 1.26.0, source: https://github.com/apple/swift-protobuf @@ -191,6 +118,8 @@ name: SwiftMessages, nameSpecified: SwiftMessages, owner: SwiftKickMobile, versi name: SwiftUI-Introspect, nameSpecified: Introspect, owner: siteline, version: 0.12.0, source: https://github.com/siteline/SwiftUI-Introspect +name: Tabman, nameSpecified: Tabman, owner: uias, version: 3.2.0, source: https://github.com/uias/Tabman + name: UICKeyChainStore, nameSpecified: UICKeyChainStore, owner: kishikawakatsumi, version: 2.2.1, source: https://github.com/kishikawakatsumi/UICKeyChainStore name: YYWebImage, nameSpecified: YYWebImage, owner: SRGSSR, version: 1.0.5-srg3, source: https://github.com/SRGSSR/YYWebImage diff --git a/Application/Resources/Settings.bundle/com.mono0926.LicensePlist.plist b/Application/Resources/Settings.bundle/com.mono0926.LicensePlist.plist index c3e5a1ec9..635e2e1aa 100755 --- a/Application/Resources/Settings.bundle/com.mono0926.LicensePlist.plist +++ b/Application/Resources/Settings.bundle/com.mono0926.LicensePlist.plist @@ -14,7 +14,7 @@ File com.mono0926.LicensePlist/abseil-cpp-binary Title - abseil (1.2024011601.0) + abseil (1.2024011601.1) Type PSChildPaneSpecifier @@ -30,7 +30,7 @@ File com.mono0926.LicensePlist/app-check Title - AppCheck (10.18.1) + AppCheck (10.19.1) Type PSChildPaneSpecifier @@ -38,7 +38,7 @@ File com.mono0926.LicensePlist/appcenter-sdk-apple Title - AppCenter (5.0.4) + AppCenter (5.0.5) Type PSChildPaneSpecifier @@ -70,7 +70,7 @@ File com.mono0926.LicensePlist/firebase-ios-sdk Title - Firebase (10.23.1) + Firebase (10.27.0) Type PSChildPaneSpecifier @@ -102,7 +102,7 @@ File com.mono0926.LicensePlist/GoogleAppMeasurement Title - GoogleAppMeasurement (10.23.1) + GoogleAppMeasurement (10.27.0) Type PSChildPaneSpecifier @@ -126,7 +126,7 @@ File com.mono0926.LicensePlist/GoogleUtilities Title - GoogleUtilities (7.13.1) + GoogleUtilities (7.13.3) Type PSChildPaneSpecifier @@ -134,7 +134,7 @@ File com.mono0926.LicensePlist/grpc-binary Title - gRPC (1.62.1) + gRPC (1.62.2) Type PSChildPaneSpecifier @@ -142,7 +142,7 @@ File com.mono0926.LicensePlist/gtm-session-fetcher Title - gtm-session-fetcher (3.3.2) + gtm-session-fetcher (3.4.1) Type PSChildPaneSpecifier @@ -158,7 +158,7 @@ File com.mono0926.LicensePlist/ios-library Title - Airship (16.12.6) + Airship (16.12.7) Type PSChildPaneSpecifier @@ -166,7 +166,7 @@ File com.mono0926.LicensePlist/iOSV5 Title - TagCommander SDK V5 (5.4.6) + TagCommander SDK V5 (5.4.9) Type PSChildPaneSpecifier @@ -174,7 +174,7 @@ File com.mono0926.LicensePlist/leveldb Title - leveldb (1.22.4) + leveldb (1.22.5) Type PSChildPaneSpecifier @@ -202,30 +202,6 @@ Type PSChildPaneSpecifier - - File - com.mono0926.LicensePlist/MaterialComponents - Title - MaterialComponents (118.2.0) - Type - PSChildPaneSpecifier - - - File - com.mono0926.LicensePlist/MDFInternationalization - Title - MDFInternationalization (3.0.0) - Type - PSChildPaneSpecifier - - - File - com.mono0926.LicensePlist/MDFTextAccessibility - Title - MDFTextAccessibility (2.0.1) - Type - PSChildPaneSpecifier - File com.mono0926.LicensePlist/nanopb @@ -250,6 +226,14 @@ Type PSChildPaneSpecifier + + File + com.mono0926.LicensePlist/Pageboy + Title + Pageboy (4.2.0) + Type + PSChildPaneSpecifier + File com.mono0926.LicensePlist/paper-onboarding @@ -262,7 +246,7 @@ File com.mono0926.LicensePlist/PLCrashReporter Title - PLCrashReporter (1.11.1) + PLCrashReporter (1.11.2) Type PSChildPaneSpecifier @@ -302,7 +286,7 @@ File com.mono0926.LicensePlist/srgdataprovider-apple Title - SRGDataProvider (19.0.3) + SRGDataProvider (19.0.4) Type PSChildPaneSpecifier @@ -326,7 +310,7 @@ File com.mono0926.LicensePlist/srgletterbox-apple Title - SRGLetterbox (9.3.0) + SRGLetterbox (9.3.1) Type PSChildPaneSpecifier @@ -366,7 +350,7 @@ File com.mono0926.LicensePlist/swift-collections Title - swift-collections (1.1.0) + swift-collections (1.1.1) Type PSChildPaneSpecifier @@ -394,6 +378,14 @@ Type PSChildPaneSpecifier + + File + com.mono0926.LicensePlist/Tabman + Title + Tabman (3.2.0) + Type + PSChildPaneSpecifier + File com.mono0926.LicensePlist/UICKeyChainStore diff --git a/Application/Resources/Settings.bundle/com.mono0926.LicensePlist/MDFInternationalization.plist b/Application/Resources/Settings.bundle/com.mono0926.LicensePlist/MDFInternationalization.plist deleted file mode 100644 index b8fcbc147..000000000 --- a/Application/Resources/Settings.bundle/com.mono0926.LicensePlist/MDFInternationalization.plist +++ /dev/null @@ -1,219 +0,0 @@ - - - - - PreferenceSpecifiers - - - FooterText - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - License - unknown - Type - PSGroupSpecifier - - - - diff --git a/Application/Resources/Settings.bundle/com.mono0926.LicensePlist/MDFTextAccessibility.plist b/Application/Resources/Settings.bundle/com.mono0926.LicensePlist/MDFTextAccessibility.plist deleted file mode 100644 index b8fcbc147..000000000 --- a/Application/Resources/Settings.bundle/com.mono0926.LicensePlist/MDFTextAccessibility.plist +++ /dev/null @@ -1,219 +0,0 @@ - - - - - PreferenceSpecifiers - - - FooterText - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - License - unknown - Type - PSGroupSpecifier - - - - diff --git a/Application/Resources/Settings.bundle/com.mono0926.LicensePlist/MaterialComponents.plist b/Application/Resources/Settings.bundle/com.mono0926.LicensePlist/MaterialComponents.plist deleted file mode 100644 index 9a8234436..000000000 --- a/Application/Resources/Settings.bundle/com.mono0926.LicensePlist/MaterialComponents.plist +++ /dev/null @@ -1,219 +0,0 @@ - - - - - PreferenceSpecifiers - - - FooterText - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - License - Apache-2.0 - Type - PSGroupSpecifier - - - - diff --git a/Application/Resources/Settings.bundle/com.mono0926.LicensePlist/Pageboy.plist b/Application/Resources/Settings.bundle/com.mono0926.LicensePlist/Pageboy.plist new file mode 100644 index 000000000..0dcc1c200 --- /dev/null +++ b/Application/Resources/Settings.bundle/com.mono0926.LicensePlist/Pageboy.plist @@ -0,0 +1,38 @@ + + + + + PreferenceSpecifiers + + + FooterText + MIT License + +Copyright (c) 2022 UI At Six + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + License + MIT + Type + PSGroupSpecifier + + + + diff --git a/Application/Resources/Settings.bundle/com.mono0926.LicensePlist/Tabman.plist b/Application/Resources/Settings.bundle/com.mono0926.LicensePlist/Tabman.plist new file mode 100644 index 000000000..0dcc1c200 --- /dev/null +++ b/Application/Resources/Settings.bundle/com.mono0926.LicensePlist/Tabman.plist @@ -0,0 +1,38 @@ + + + + + PreferenceSpecifiers + + + FooterText + MIT License + +Copyright (c) 2022 UI At Six + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + License + MIT + Type + PSGroupSpecifier + + + + diff --git a/Application/Sources/Application/AppDelegate.m b/Application/Sources/Application/AppDelegate.m index 833466a63..5285a7ccb 100755 --- a/Application/Sources/Application/AppDelegate.m +++ b/Application/Sources/Application/AppDelegate.m @@ -339,7 +339,7 @@ - (void)playbackDidContinueAutomatically:(NSNotification *)notification SRGMedia *media = notification.userInfo[SRGLetterboxMediaKey]; if (media) { [[AnalyticsEventObjC continuousPlaybackWithAction:AnalyticsContiniousPlaybackActionPlayAutomatic - mediaUrn:media.URN] + mediaUrn:media.URN] send]; } } diff --git a/Application/Sources/Application/Navigation.swift b/Application/Sources/Application/Navigation.swift index 9d6331377..0942f608b 100644 --- a/Application/Sources/Application/Navigation.swift +++ b/Application/Sources/Application/Navigation.swift @@ -10,313 +10,307 @@ import SRGAppearanceSwift import SRGDataProviderModel import SwiftUI #if os(tvOS) -import TvOSTextViewer + import TvOSTextViewer #endif import UIKit private var cancellable: AnyCancellable? #if os(tvOS) -private var isPresenting = false -private var cancellables = Set() - -extension UIViewController { - func navigateToMedia(_ media: SRGMedia, play: Bool = false, mediaAnalyticsClickEvent: AnalyticsClickEvent? = nil, playAnalyticsClickEvent: AnalyticsClickEvent? = nil, from program: SRGProgram? = nil, animated: Bool = true, completion: (() -> Void)? = nil) { - if !play && media.contentType != .livestream { - mediaAnalyticsClickEvent?.send() - - let hostController = UIHostingController(rootView: MediaDetailView(media: media, playAnalyticsClickEvent: playAnalyticsClickEvent)) - present(hostController, animated: animated, completion: completion) - } - else { - playAnalyticsClickEvent?.send() - - let letterboxViewController = SRGLetterboxViewController() - letterboxViewController.delegate = LetterboxDelegate.shared - - let controller = letterboxViewController.controller - let playlist = PlaylistForURN(media.urn) - controller.playlistDataSource = playlist - controller.playbackTransitionDelegate = playlist - ApplicationConfigurationApplyControllerSettings(controller) - - controller.addPeriodicTimeObserver(forInterval: CMTimeMakeWithSeconds(1, preferredTimescale: Int32(NSEC_PER_SEC)), queue: nil) { _ in - HistoryUpdateLetterboxPlaybackProgress(controller) - } - - controller.publisher(for: \.continuousPlaybackUpcomingMedia) - .sink { upcomingMedia in - guard let upcomingMedia else { return } - - AnalyticsEvent.continuousPlayback(action: .display, - mediaUrn: upcomingMedia.urn) - .send() + private var isPresenting = false + private var cancellables = Set() + + extension UIViewController { + func navigateToMedia(_ media: SRGMedia, play: Bool = false, mediaAnalyticsClickEvent: AnalyticsClickEvent? = nil, playAnalyticsClickEvent: AnalyticsClickEvent? = nil, from _: SRGProgram? = nil, animated: Bool = true, completion: (() -> Void)? = nil) { + if !play, media.contentType != .livestream { + mediaAnalyticsClickEvent?.send() + + let hostController = UIHostingController(rootView: MediaDetailView(media: media, playAnalyticsClickEvent: playAnalyticsClickEvent)) + present(hostController, animated: animated, completion: completion) + } else { + playAnalyticsClickEvent?.send() + + let letterboxViewController = SRGLetterboxViewController() + letterboxViewController.delegate = LetterboxDelegate.shared + + let controller = letterboxViewController.controller + let playlist = PlaylistForURN(media.urn) + controller.playlistDataSource = playlist + controller.playbackTransitionDelegate = playlist + ApplicationConfigurationApplyControllerSettings(controller) + + controller.addPeriodicTimeObserver(forInterval: CMTimeMakeWithSeconds(1, preferredTimescale: Int32(NSEC_PER_SEC)), queue: nil) { _ in + HistoryUpdateLetterboxPlaybackProgress(controller) } - .store(in: &cancellables) - - let position = HistoryResumePlaybackPositionForMedia(media) - controller.playMedia(media, at: position, withPreferredSettings: nil) - - present(letterboxViewController, animated: animated) { - SRGAnalyticsTracker.shared.trackPageView(withTitle: AnalyticsPageTitle.player.rawValue, type: AnalyticsPageType.detail.rawValue, levels: [AnalyticsPageLevel.play.rawValue]) - if let completion { - completion() + + controller.publisher(for: \.continuousPlaybackUpcomingMedia) + .sink { upcomingMedia in + guard let upcomingMedia else { return } + + AnalyticsEvent.continuousPlayback(action: .display, + mediaUrn: upcomingMedia.urn) + .send() + } + .store(in: &cancellables) + + let position = HistoryResumePlaybackPositionForMedia(media) + controller.playMedia(media, at: position, withPreferredSettings: nil) + + present(letterboxViewController, animated: animated) { + SRGAnalyticsTracker.shared.trackPageView(withTitle: AnalyticsPageTitle.player.rawValue, type: AnalyticsPageType.detail.rawValue, levels: [AnalyticsPageLevel.play.rawValue]) + if let completion { + completion() + } } } } - } - - func navigateToShow(_ show: SRGShow, animated: Bool = true, completion: (() -> Void)? = nil) { - let pageViewController = PageViewController(id: .show(show)) - present(pageViewController, animated: animated, completion: completion) - } - - func navigateToTopic(_ topic: SRGTopic, animated: Bool = true, completion: (() -> Void)? = nil) { - let pageViewController = PageViewController(id: .topic(topic)) - present(pageViewController, animated: animated, completion: completion) - } - - func navigateToProgram(_ program: SRGProgram, in channel: SRGChannel, animated: Bool = true, completion: (() -> Void)? = nil) { - cancellable = mediaPublisher(for: program, in: channel)? - .receive(on: DispatchQueue.main) - .sink { _ in - // No error banners displayed on tvOS yet - } receiveValue: { [weak self] media in - let playAnalyticsClickEvent = media.contentType == .livestream ? - AnalyticsClickEvent.tvGuidePlayLivestream(program: program, channel: channel, source: .grid) : - AnalyticsClickEvent.tvGuidePlayMedia(media: media, programIsLive: (program.startDate...program.endDate).contains(Date()), channel: channel) - let mediaAnalyticsClickEvent = AnalyticsClickEvent.tvGuideOpenInfoBox(program: program, programGuideLayout: .grid) - - self?.navigateToMedia(media, mediaAnalyticsClickEvent: mediaAnalyticsClickEvent, playAnalyticsClickEvent: playAnalyticsClickEvent, from: program, animated: animated, completion: completion) - } - } - - func navigateToPage(_ page: SRGContentPage, animated: Bool = true, completion: (() -> Void)? = nil) { - let pageViewController = PageViewController(id: .page(page)) - present(pageViewController, animated: animated, completion: completion) - } - - func navigateToSection(_ section: Content.Section, filter: SectionFiltering?, animated: Bool = true, completion: (() -> Void)? = nil) { - let sectionViewController = SectionViewController(section: section, filter: filter) - present(sectionViewController, animated: animated, completion: completion) - } - - func navigateToApplicationSection(_ applicationSection: ApplicationSection, animated: Bool = true, completion: (() -> Void)? = nil) { - switch applicationSection { - case .history: - present(SectionViewController.historyViewController(), animated: animated, completion: completion) - case .favorites: - present(SectionViewController.favoriteShowsViewController(), animated: animated, completion: completion) - case .watchLater: - present(SectionViewController.watchLaterViewController(), animated: animated, completion: completion) - default: - break + + func navigateToShow(_ show: SRGShow, animated: Bool = true, completion: (() -> Void)? = nil) { + let pageViewController = PageViewController(id: .show(show)) + present(pageViewController, animated: animated, completion: completion) } - } - - func navigateToText(_ text: String, animated: Bool = true, completion: (() -> Void)? = nil) { - let textViewController = TvOSTextViewerViewController() - textViewController.text = text - textViewController.textAttributes = [ - .foregroundColor: UIColor.white, - .font: SRGFont.font(.body) as UIFont - ] - textViewController.textEdgeInsets = UIEdgeInsets(top: 100, left: 250, bottom: 100, right: 250) - textViewController.modalPresentationStyle = .overFullScreen - present(textViewController, animated: animated, completion: completion) - } - - private func mediaPublisher(for program: SRGProgram, in channel: SRGChannel) -> AnyPublisher? { - if program.play_containsDate(Date()) { - return SRGDataProvider.current!.tvLivestreams(for: channel.vendor) - .compactMap { $0.first(where: { $0.channel == channel }) } - .eraseToAnyPublisher() + + func navigateToTopic(_ topic: SRGTopic, animated: Bool = true, completion: (() -> Void)? = nil) { + let pageViewController = PageViewController(id: .topic(topic)) + present(pageViewController, animated: animated, completion: completion) + } + + func navigateToProgram(_ program: SRGProgram, in channel: SRGChannel, animated: Bool = true, completion: (() -> Void)? = nil) { + cancellable = mediaPublisher(for: program, in: channel)? + .receive(on: DispatchQueue.main) + .sink { _ in + // No error banners displayed on tvOS yet + } receiveValue: { [weak self] media in + let playAnalyticsClickEvent = media.contentType == .livestream ? + AnalyticsClickEvent.tvGuidePlayLivestream(program: program, channel: channel, source: .grid) : + AnalyticsClickEvent.tvGuidePlayMedia(media: media, programIsLive: (program.startDate ... program.endDate).contains(Date()), channel: channel) + let mediaAnalyticsClickEvent = AnalyticsClickEvent.tvGuideOpenInfoBox(program: program, programGuideLayout: .grid) + + self?.navigateToMedia(media, mediaAnalyticsClickEvent: mediaAnalyticsClickEvent, playAnalyticsClickEvent: playAnalyticsClickEvent, from: program, animated: animated, completion: completion) + } } - else if let mediaUrn = program.mediaURN { - return SRGDataProvider.current!.media(withUrn: mediaUrn) + + func navigateToPage(_ page: SRGContentPage, animated: Bool = true, completion: (() -> Void)? = nil) { + let pageViewController = PageViewController(id: .page(page)) + present(pageViewController, animated: animated, completion: completion) } - else { - return nil + + func navigateToSection(_ section: Content.Section, filter: SectionFiltering?, animated: Bool = true, completion: (() -> Void)? = nil) { + let sectionViewController = SectionViewController(section: section, filter: filter) + present(sectionViewController, animated: animated, completion: completion) + } + + func navigateToApplicationSection(_ applicationSection: ApplicationSection, animated: Bool = true, completion: (() -> Void)? = nil) { + switch applicationSection { + case .history: + present(SectionViewController.historyViewController(), animated: animated, completion: completion) + case .favorites: + present(SectionViewController.favoriteShowsViewController(), animated: animated, completion: completion) + case .watchLater: + present(SectionViewController.watchLaterViewController(), animated: animated, completion: completion) + default: + break + } + } + + func navigateToText(_ text: String, animated: Bool = true, completion: (() -> Void)? = nil) { + let textViewController = TvOSTextViewerViewController() + textViewController.text = text + textViewController.textAttributes = [ + .foregroundColor: UIColor.white, + .font: SRGFont.font(.body) as UIFont + ] + textViewController.textEdgeInsets = UIEdgeInsets(top: 100, left: 250, bottom: 100, right: 250) + textViewController.modalPresentationStyle = .overFullScreen + present(textViewController, animated: animated, completion: completion) + } + + private func mediaPublisher(for program: SRGProgram, in channel: SRGChannel) -> AnyPublisher? { + if program.play_containsDate(Date()) { + SRGDataProvider.current!.tvLivestreams(for: channel.vendor) + .compactMap { $0.first(where: { $0.channel == channel }) } + .eraseToAnyPublisher() + } else if let mediaUrn = program.mediaURN { + SRGDataProvider.current!.media(withUrn: mediaUrn) + } else { + nil + } } } -} -func navigateToMedia(_ media: SRGMedia, play: Bool = false, mediaAnalyticsClickEvent: AnalyticsClickEvent? = nil, playAnalyticsClickEvent: AnalyticsClickEvent? = nil, animated: Bool = true) { - guard !isPresenting, let topViewController = UIApplication.shared.mainTopViewController else { return } - isPresenting = true - topViewController.navigateToMedia(media, play: play, mediaAnalyticsClickEvent: mediaAnalyticsClickEvent, playAnalyticsClickEvent: playAnalyticsClickEvent, animated: animated) { - isPresenting = false + func navigateToMedia(_ media: SRGMedia, play: Bool = false, mediaAnalyticsClickEvent: AnalyticsClickEvent? = nil, playAnalyticsClickEvent: AnalyticsClickEvent? = nil, animated: Bool = true) { + guard !isPresenting, let topViewController = UIApplication.shared.mainTopViewController else { return } + isPresenting = true + topViewController.navigateToMedia(media, play: play, mediaAnalyticsClickEvent: mediaAnalyticsClickEvent, playAnalyticsClickEvent: playAnalyticsClickEvent, animated: animated) { + isPresenting = false + } } -} -func navigateToShow(_ show: SRGShow, animated: Bool = true) { - guard !isPresenting, let topViewController = UIApplication.shared.mainTopViewController else { return } - isPresenting = true - topViewController.navigateToShow(show, animated: animated) { - isPresenting = false + func navigateToShow(_ show: SRGShow, animated: Bool = true) { + guard !isPresenting, let topViewController = UIApplication.shared.mainTopViewController else { return } + isPresenting = true + topViewController.navigateToShow(show, animated: animated) { + isPresenting = false + } } -} -func navigateToPage(_ page: SRGContentPage, animated: Bool = true) { - guard !isPresenting, let topViewController = UIApplication.shared.mainTopViewController else { return } - isPresenting = true - topViewController.navigateToPage(page, animated: animated) { - isPresenting = false + func navigateToPage(_ page: SRGContentPage, animated: Bool = true) { + guard !isPresenting, let topViewController = UIApplication.shared.mainTopViewController else { return } + isPresenting = true + topViewController.navigateToPage(page, animated: animated) { + isPresenting = false + } } -} -func navigateToSection(_ section: Content.Section, filter: SectionFiltering?, animated: Bool = true) { - if let microPageId = section.properties.openContentPageId { - openContentPage(id: microPageId, animated: animated) - } else { - openSectionPage(section: section, filter: filter, animated: animated) + func navigateToSection(_ section: Content.Section, filter: SectionFiltering?, animated: Bool = true) { + if let microPageId = section.properties.openContentPageId { + openContentPage(id: microPageId, animated: animated) + } else { + openSectionPage(section: section, filter: filter, animated: animated) + } } -} -private func openSectionPage(section: Content.Section, filter: SectionFiltering?, animated: Bool) { - guard !isPresenting, let topViewController = UIApplication.shared.mainTopViewController else { return } - isPresenting = true - topViewController.navigateToSection(section, filter: filter, animated: animated) { - isPresenting = false + private func openSectionPage(section: Content.Section, filter: SectionFiltering?, animated: Bool) { + guard !isPresenting, let topViewController = UIApplication.shared.mainTopViewController else { return } + isPresenting = true + topViewController.navigateToSection(section, filter: filter, animated: animated) { + isPresenting = false + } } -} -private func openContentPage(id: String, animated: Bool) { - guard !isPresenting, let topViewController = UIApplication.shared.mainTopViewController else { return } - isPresenting = true + private func openContentPage(id: String, animated: Bool) { + guard !isPresenting, let topViewController = UIApplication.shared.mainTopViewController else { return } + isPresenting = true - SRGDataProvider.current!.contentPage(for: ApplicationConfiguration.shared.vendor, uid: id) - .receive(on: DispatchQueue.main) - .sink { _ in - // No error banners displayed on tvOS yet - isPresenting = false - } receiveValue: { contentPage in - topViewController.navigateToPage(contentPage, animated: animated) { + SRGDataProvider.current!.contentPage(for: ApplicationConfiguration.shared.vendor, uid: id) + .receive(on: DispatchQueue.main) + .sink { _ in + // No error banners displayed on tvOS yet isPresenting = false + } receiveValue: { contentPage in + topViewController.navigateToPage(contentPage, animated: animated) { + isPresenting = false + } } + .store(in: &cancellables) + } + + func navigateToTopic(_ topic: SRGTopic, animated: Bool = true) { + guard !isPresenting, let topViewController = UIApplication.shared.mainTopViewController else { return } + isPresenting = true + topViewController.navigateToTopic(topic, animated: animated) { + isPresenting = false } - .store(in: &cancellables) -} - -func navigateToTopic(_ topic: SRGTopic, animated: Bool = true) { - guard !isPresenting, let topViewController = UIApplication.shared.mainTopViewController else { return } - isPresenting = true - topViewController.navigateToTopic(topic, animated: animated) { - isPresenting = false } -} -func navigateToApplicationSection(_ applicationSection: ApplicationSection, animated: Bool = true) { - guard !isPresenting, let topViewController = UIApplication.shared.mainTopViewController else { return } - isPresenting = true - topViewController.navigateToApplicationSection(applicationSection, animated: animated) { - isPresenting = false + func navigateToApplicationSection(_ applicationSection: ApplicationSection, animated: Bool = true) { + guard !isPresenting, let topViewController = UIApplication.shared.mainTopViewController else { return } + isPresenting = true + topViewController.navigateToApplicationSection(applicationSection, animated: animated) { + isPresenting = false + } } -} -func navigateToText(_ text: String, animated: Bool = true) { - guard !isPresenting, let topViewController = UIApplication.shared.mainTopViewController else { return } - isPresenting = true - topViewController.navigateToText(text, animated: animated) { - isPresenting = false + func navigateToText(_ text: String, animated: Bool = true) { + guard !isPresenting, let topViewController = UIApplication.shared.mainTopViewController else { return } + isPresenting = true + topViewController.navigateToText(text, animated: animated) { + isPresenting = false + } } -} #endif #if os(iOS) -extension UIViewController { - @objc func navigateToNotification(_ notification: UserNotification, animated: Bool = true) { - UserNotification.saveNotification(notification, read: true) - - if let mediaUrn = notification.mediaURN { - UserConsentHelper.waitCollectingConsentRetain() - cancellable = SRGDataProvider.current!.media(withUrn: mediaUrn) - .receive(on: DispatchQueue.main) - .sink { result in - if case let .failure(error) = result { - Banner.showError(error as NSError) - UserConsentHelper.waitCollectingConsentRelease() + extension UIViewController { + @objc func navigateToNotification(_ notification: UserNotification, animated: Bool = true) { + UserNotification.saveNotification(notification, read: true) + + if let mediaUrn = notification.mediaURN { + UserConsentHelper.waitCollectingConsentRetain() + cancellable = SRGDataProvider.current!.media(withUrn: mediaUrn) + .receive(on: DispatchQueue.main) + .sink { result in + if case let .failure(error) = result { + Banner.showError(error as NSError) + UserConsentHelper.waitCollectingConsentRelease() + } + } receiveValue: { [weak self] media in + guard let self else { return } + play_presentMediaPlayer(with: media, position: nil, airPlaySuggestions: true, fromPushNotification: false, animated: animated) { _ in + AnalyticsEvent.notification(action: .playMedia, + from: .application, + uid: mediaUrn, + overrideSource: notification.showURN, + overrideType: UserNotificationTypeString(notification.type)) + .send() + UserConsentHelper.waitCollectingConsentRelease() + } } - } receiveValue: { [weak self] media in - guard let self else { return } - self.play_presentMediaPlayer(with: media, position: nil, airPlaySuggestions: true, fromPushNotification: false, animated: animated) { _ in - AnalyticsEvent.notification(action: .playMedia, + } else if let showUrn = notification.showURN { + UserConsentHelper.waitCollectingConsentRetain() + cancellable = SRGDataProvider.current!.show(withUrn: showUrn) + .receive(on: DispatchQueue.main) + .sink { result in + if case let .failure(error) = result { + Banner.showError(error as NSError) + UserConsentHelper.waitCollectingConsentRelease() + } + } receiveValue: { [weak self] show in + guard let navigationController = self?.navigationController else { return } + let pageViewController = PageViewController(id: .show(show)) + navigationController.pushViewController(pageViewController, animated: animated) + + AnalyticsEvent.notification(action: .displayShow, from: .application, - uid: mediaUrn, - overrideSource: notification.showURN, + uid: showUrn, overrideType: UserNotificationTypeString(notification.type)) - .send() + .send() UserConsentHelper.waitCollectingConsentRelease() } - } - } - else if let showUrn = notification.showURN { - UserConsentHelper.waitCollectingConsentRetain() - cancellable = SRGDataProvider.current!.show(withUrn: showUrn) - .receive(on: DispatchQueue.main) - .sink { result in - if case let .failure(error) = result { - Banner.showError(error as NSError) - UserConsentHelper.waitCollectingConsentRelease() - } - } receiveValue: { [weak self] show in - guard let navigationController = self?.navigationController else { return } - let pageViewController = PageViewController(id: .show(show)) - navigationController.pushViewController(pageViewController, animated: animated) - - AnalyticsEvent.notification(action: .displayShow, - from: .application, - uid: showUrn, - overrideType: UserNotificationTypeString(notification.type)) + } else { + AnalyticsEvent.notification(action: .alert, + from: .application, + uid: notification.body, + overrideType: UserNotificationTypeString(notification.type)) .send() - UserConsentHelper.waitCollectingConsentRelease() - } - } - else { - AnalyticsEvent.notification(action: .alert, - from: .application, - uid: notification.body, - overrideType: UserNotificationTypeString(notification.type)) - .send() - } - } - - func navigateToDownload(_ download: Download, animated: Bool = true) { - if let media = download.media { - play_presentMediaPlayer(with: media, position: nil, airPlaySuggestions: true, fromPushNotification: false, animated: animated, completion: nil) + } } - else { - let error = NSError( - domain: PlayErrorDomain, - code: PlayErrorCode.notFound.rawValue, - userInfo: [ - NSLocalizedDescriptionKey: NSLocalizedString("Media not available yet", comment: "Message on top screen when trying to open a media in the download list and the media is not downloaded.") - ] - ) - Banner.showError(error) + + func navigateToDownload(_ download: Download, animated: Bool = true) { + if let media = download.media { + play_presentMediaPlayer(with: media, position: nil, airPlaySuggestions: true, fromPushNotification: false, animated: animated, completion: nil) + } else { + let error = NSError( + domain: PlayErrorDomain, + code: PlayErrorCode.notFound.rawValue, + userInfo: [ + NSLocalizedDescriptionKey: NSLocalizedString("Media not available yet", comment: "Message on top screen when trying to open a media in the download list and the media is not downloaded.") + ] + ) + Banner.showError(error) + } } - } - - func navigateToItem(_ item: Content.Item, animated: Bool = true) { - switch item { - case let .media(media): - play_presentMediaPlayer(with: media, position: nil, airPlaySuggestions: true, fromPushNotification: false, animated: animated, completion: nil) - case let .show(show): - guard let navigationController else { return } - let pageViewController = PageViewController(id: .show(show)) - navigationController.pushViewController(pageViewController, animated: animated) - case let .topic(topic): - guard let navigationController else { return } - let pageViewController = PageViewController(id: .topic(topic)) - navigationController.pushViewController(pageViewController, animated: animated) - case let .download(download): - navigateToDownload(download, animated: animated) - case let .notification(notification): - navigateToNotification(notification, animated: animated) - default: - break + + func navigateToItem(_ item: Content.Item, animated: Bool = true) { + switch item { + case let .media(media): + play_presentMediaPlayer(with: media, position: nil, airPlaySuggestions: true, fromPushNotification: false, animated: animated, completion: nil) + case let .show(show): + guard let navigationController else { return } + let pageViewController = PageViewController(id: .show(show)) + navigationController.pushViewController(pageViewController, animated: animated) + case let .topic(topic): + guard let navigationController else { return } + let pageViewController = PageViewController(id: .topic(topic)) + navigationController.pushViewController(pageViewController, animated: animated) + case let .download(download): + navigateToDownload(download, animated: animated) + case let .notification(notification): + navigateToNotification(notification, animated: animated) + default: + break + } } } -} #endif diff --git a/Application/Sources/Application/SceneDelegate.m b/Application/Sources/Application/SceneDelegate.m index d131940c9..e0590a6c5 100644 --- a/Application/Sources/Application/SceneDelegate.m +++ b/Application/Sources/Application/SceneDelegate.m @@ -61,6 +61,8 @@ - (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session op [defaults addObserver:self forKeyPath:PlaySRGSettingServiceIdentifier options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:s_kvoContext]; [defaults addObserver:self forKeyPath:PlaySRGSettingUserLocation options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:s_kvoContext]; [defaults addObserver:self forKeyPath:PlaySRGSettingPosterImages options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:s_kvoContext]; + [defaults addObserver:self forKeyPath:PlaySRGSettingSquareImages options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:s_kvoContext]; + [defaults addObserver:self forKeyPath:PlaySRGSettingAudioHomepageOption options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:s_kvoContext]; #endif } @@ -71,6 +73,8 @@ - (void)sceneDidDisconnect:(UIScene *)scene [defaults removeObserver:self forKeyPath:PlaySRGSettingServiceIdentifier]; [defaults removeObserver:self forKeyPath:PlaySRGSettingUserLocation]; [defaults removeObserver:self forKeyPath:PlaySRGSettingPosterImages]; + [defaults removeObserver:self forKeyPath:PlaySRGSettingSquareImages]; + [defaults removeObserver:self forKeyPath:PlaySRGSettingAudioHomepageOption]; #endif } @@ -537,7 +541,8 @@ - (void)openSectionUid:(NSString *)sectionUid [UserConsentHelper waitCollectingConsentRetain]; [[SRGDataProvider.currentDataProvider contentSectionForVendor:ApplicationConfiguration.sharedApplicationConfiguration.vendor uid:sectionUid published:YES withCompletionBlock:^(SRGContentSection * _Nullable contentSection, NSHTTPURLResponse * _Nullable HTTPResponse, NSError * _Nullable error) { if (contentSection) { - SectionViewController *sectionViewController = [SectionViewController viewControllerForContentSection:contentSection]; + // FIXME: is section always videoOrTV content type? + SectionViewController *sectionViewController = [SectionViewController viewControllerForContentSection:contentSection contentType:ContentTypeVideoOrTV]; [self.rootTabBarController pushViewController:sectionViewController animated:YES]; } else { @@ -555,7 +560,7 @@ - (void)openSectionUid:(NSString *)sectionUid - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { if (s_kvoContext == context) { - if ([keyPath isEqualToString:PlaySRGSettingServiceIdentifier] || [keyPath isEqualToString:PlaySRGSettingUserLocation] || [keyPath isEqualToString:PlaySRGSettingPosterImages]) { + if ([keyPath isEqualToString:PlaySRGSettingServiceIdentifier] || [keyPath isEqualToString:PlaySRGSettingUserLocation] || [keyPath isEqualToString:PlaySRGSettingPosterImages] || [keyPath isEqualToString:PlaySRGSettingSquareImages] || [keyPath isEqualToString:PlaySRGSettingAudioHomepageOption]) { // Entirely reload the view controller hierarchy to ensure all configuration changes are reflected in the // user interface. Scheduled for the next run loop to have the same code in the app delegate (updating the // data provider) executed first. diff --git a/Application/Sources/Bridges/PlaySRG-ObjectiveC.h b/Application/Sources/Bridges/PlaySRG-ObjectiveC.h index da4a358cd..237744b38 100755 --- a/Application/Sources/Bridges/PlaySRG-ObjectiveC.h +++ b/Application/Sources/Bridges/PlaySRG-ObjectiveC.h @@ -20,7 +20,6 @@ #import "Favorites.h" #import "History.h" #import "Layout.h" -#import "MaterialTabs.h" #import "MediaPlayerViewController.h" #import "MediaPlayerViewController+Private.h" #import "MediaPreviewViewController.h" diff --git a/Application/Sources/Bridges/SwiftMessagesBridge.swift b/Application/Sources/Bridges/SwiftMessagesBridge.swift index a78a28a05..4ce086504 100755 --- a/Application/Sources/Bridges/SwiftMessagesBridge.swift +++ b/Application/Sources/Bridges/SwiftMessagesBridge.swift @@ -24,31 +24,30 @@ final class SwiftMessagesBridge: NSObject { */ @objc static func show(_ message: String, accessibilityPrefix: String?, image: UIImage?, backgroundColor: UIColor?, foregroundColor: UIColor?, sticky: Bool) { SwiftMessages.hideAll() - + let messageView = MessageView.viewFromNib(layout: .cardView) messageView.button?.isHidden = true messageView.bodyLabel?.font = SRGFont.font(.body) messageView.configureDropShadow() - + messageView.configureContent(title: nil, body: message, iconImage: nil, iconText: nil, buttonImage: nil, buttonTitle: nil, buttonTapHandler: nil) messageView.configureTheme(backgroundColor: backgroundColor ?? UIColor.white, foregroundColor: foregroundColor ?? UIColor.black) - + messageView.accessibilityPrefix = accessibilityPrefix - + messageView.iconImageView?.image = image messageView.iconImageView?.isHidden = (image == nil) - + messageView.tapHandler = { _ in SwiftMessages.hide() } - + var config = SwiftMessages.defaultConfig if sticky { config.duration = .forever - } - else { + } else { config.duration = .seconds(seconds: 4) } config.presentationStyle = .bottom - + // Set a presentation context (with a preference for navigation controllers). A context is required so that // the notification rotation behavior matches the one of the currently visible view controller. var presentationController = UIApplication.shared.mainTopViewController @@ -58,16 +57,16 @@ final class SwiftMessagesBridge: NSObject { } presentationController = presentationController?.parent } - + if let presentationController { config.presentationContext = .viewController(presentationController) } - + // Remark: VoiceOver is supported natively, but with the system language (not the one we might set on the // UIApplication instance) SwiftMessages.show(config: config, view: messageView) } - + /** * Hide all notification messages. */ diff --git a/Application/Sources/Browser/WebViewController.m b/Application/Sources/Browser/WebViewController.m index 22a0c6a79..64d32e164 100755 --- a/Application/Sources/Browser/WebViewController.m +++ b/Application/Sources/Browser/WebViewController.m @@ -67,7 +67,7 @@ - (void)loadView { UIView *view = [[UIView alloc] initWithFrame:UIScreen.mainScreen.bounds]; view.backgroundColor = UIColor.srg_gray16Color; - + // WKWebView cannot be instantiated in storyboards, do it programmatically WKWebView *webView = [[WKWebView alloc] init]; webView.opaque = NO; @@ -77,7 +77,7 @@ - (void)loadView webView.scrollView.indicatorStyle = UIScrollViewIndicatorStyleWhite; webView.scrollView.delegate = self; [view insertSubview:webView atIndex:0]; - + webView.translatesAutoresizingMaskIntoConstraints = NO; [NSLayoutConstraint activateConstraints:@[ [webView.topAnchor constraintEqualToAnchor:view.safeAreaLayoutGuide.topAnchor], @@ -86,56 +86,56 @@ - (void)loadView [webView.trailingAnchor constraintEqualToAnchor:view.safeAreaLayoutGuide.trailingAnchor] ]]; self.webView = webView; - + if (self.customizationBlock) { self.customizationBlock(webView); } - + UIImageView *loadingImageView = [UIImageView play_largeLoadingImageViewWithTintColor:UIColor.srg_grayD2Color]; loadingImageView.hidden = YES; [view insertSubview:loadingImageView atIndex:0]; self.loadingImageView = loadingImageView; - + loadingImageView.translatesAutoresizingMaskIntoConstraints = NO; [NSLayoutConstraint activateConstraints:@[ [loadingImageView.centerXAnchor constraintEqualToAnchor:view.safeAreaLayoutGuide.centerXAnchor], [loadingImageView.centerYAnchor constraintEqualToAnchor:view.safeAreaLayoutGuide.centerYAnchor] ]]; - + UILabel *errorLabel = [[UILabel alloc] init]; errorLabel.textColor = UIColor.whiteColor; errorLabel.font = [SRGFont fontWithStyle:SRGFontStyleBody]; [view addSubview:errorLabel]; self.errorLabel = errorLabel; - + errorLabel.translatesAutoresizingMaskIntoConstraints = NO; [NSLayoutConstraint activateConstraints:@[ [errorLabel.leadingAnchor constraintEqualToAnchor:view.safeAreaLayoutGuide.leadingAnchor constant:40.f], [errorLabel.trailingAnchor constraintEqualToAnchor:view.safeAreaLayoutGuide.trailingAnchor constant:-40.f], [errorLabel.centerYAnchor constraintEqualToAnchor:view.centerYAnchor] ]]; - + UIProgressView *progressView = [[UIProgressView alloc] initWithProgressViewStyle:UIProgressViewStyleBar]; progressView.progressTintColor = UIColor.srg_redColor; [view addSubview:progressView]; self.progressView = progressView; - + progressView.translatesAutoresizingMaskIntoConstraints = NO; [NSLayoutConstraint activateConstraints:@[ [progressView.leadingAnchor constraintEqualToAnchor:view.leadingAnchor], [progressView.trailingAnchor constraintEqualToAnchor:view.trailingAnchor], self.progressTopConstraint = [progressView.topAnchor constraintEqualToAnchor:view.safeAreaLayoutGuide.topAnchor] ]]; - + self.view = view; } - (void)viewDidLoad { [super viewDidLoad]; - + [self.webView loadRequest:self.request]; - + [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(webViewController_reachabilityDidChange:) name:FXReachabilityStatusDidChangeNotification @@ -213,7 +213,7 @@ - (void)webView:(WKWebView *)webView didStartProvisionalNavigation:(WKNavigation { self.loadingImageView.hidden = NO; self.errorLabel.text = nil; - + [UIView animateWithDuration:0.3 animations:^{ self.progressView.alpha = 1.f; }]; @@ -223,7 +223,7 @@ - (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigat { self.loadingImageView.hidden = YES; self.errorLabel.text = nil; - + [UIView animateWithDuration:0.3 animations:^{ self.webView.alpha = 1.f; self.progressView.alpha = 0.f; @@ -234,7 +234,7 @@ - (void)webView:(WKWebView *)webView didFailProvisionalNavigation:(WKNavigation { self.loadingImageView.hidden = YES; NSError *updatedError = error; - + NSURL *failingURL = ([error.domain isEqualToString:NSURLErrorDomain]) ? error.userInfo[NSURLErrorFailingURLErrorKey] : nil; if (failingURL && ! [failingURL.scheme isEqualToString:@"http"] && ! [failingURL.scheme isEqualToString:@"https"] && ! [failingURL.scheme isEqualToString:@"file"]) { updatedError = nil; @@ -242,7 +242,7 @@ - (void)webView:(WKWebView *)webView didFailProvisionalNavigation:(WKNavigation if ([updatedError.domain isEqualToString:NSURLErrorDomain]) { self.errorLabel.text = [NSHTTPURLResponse srg_localizedStringForURLErrorCode:updatedError.code]; - + [UIView animateWithDuration:0.3 animations:^{ self.progressView.alpha = 0.f; self.webView.alpha = 0.f; @@ -250,9 +250,9 @@ - (void)webView:(WKWebView *)webView didFailProvisionalNavigation:(WKNavigation } else { self.errorLabel.text = nil; - + [webView goBack]; - + [UIView animateWithDuration:0.3 animations:^{ self.progressView.alpha = 0.f; }]; diff --git a/Application/Sources/Calendar/CalendarViewController.h b/Application/Sources/Calendar/CalendarViewController.h index 1c74e1951..837e541de 100755 --- a/Application/Sources/Calendar/CalendarViewController.h +++ b/Application/Sources/Calendar/CalendarViewController.h @@ -15,7 +15,7 @@ NS_ASSUME_NONNULL_BEGIN @interface CalendarViewController : UIViewController +Oriented, ScrollableContentContainer, SRGAnalyticsViewTracking, UIPageViewControllerDataSource, UIPageViewControllerDelegate, UIGestureRecognizerDelegate> /** * Instantiate for medias belonging to the specified radio channel. If no channel is provided, TV medias will be diff --git a/Application/Sources/Calendar/CalendarViewController.m b/Application/Sources/Calendar/CalendarViewController.m index 5285854ec..10075dc8b 100755 --- a/Application/Sources/Calendar/CalendarViewController.m +++ b/Application/Sources/Calendar/CalendarViewController.m @@ -314,7 +314,7 @@ - (void)calendarCurrentPageDidChange:(FSCalendar *)calendar // Hidden if in the same page as today and current date is not today BOOL hidden = [NSCalendar.srg_defaultCalendar compareDate:calendar.currentPage toDate:calendar.today toUnitGranularity:unitGranularity] == NSOrderedSame - && [calendar.today isEqualToDate:dailyMediasViewController.date]; + && [calendar.today isEqualToDate:dailyMediasViewController.date]; [self setNavigationBarItemsHidden:hidden]; } diff --git a/Application/Sources/CarPlay/CarPlay+Extensions.swift b/Application/Sources/CarPlay/CarPlay+Extensions.swift index 5e3528b80..8553fafb8 100644 --- a/Application/Sources/CarPlay/CarPlay+Extensions.swift +++ b/Application/Sources/CarPlay/CarPlay+Extensions.swift @@ -12,7 +12,7 @@ extension CPListTemplate { template.controller = CarPlayTemplateListController(list: list, template: template, interfaceController: interfaceController) return template } - + static var playbackRate: CPListTemplate { let template = CPListTemplate(title: NSLocalizedString("Playback speed", comment: "Playback speed screen title"), sections: []) template.controller = CarPlayPlaybackSpeedController(template: template) @@ -26,19 +26,18 @@ extension CPInterfaceController { if controller.play_mainMedia != media { controller.playMedia(media, at: HistoryResumePlaybackPositionForMedia(media), withPreferredSettings: ApplicationSettingPlaybackSettings()) } - } - else { + } else { let controller = SRGLetterboxController() controller.playMedia(media, at: HistoryResumePlaybackPositionForMedia(media), withPreferredSettings: ApplicationSettingPlaybackSettings()) SRGLetterboxService.shared.enable(with: controller, pictureInPictureDelegate: nil) } - + if let controller = SRGLetterboxService.shared.controller { let playlist = PlaylistForURN(media.urn) controller.playlistDataSource = playlist controller.playbackTransitionDelegate = playlist } - + let nowPlayingTemplate = CPNowPlayingTemplate.shared pushTemplate(nowPlayingTemplate, animated: true) { _, _ in completion() diff --git a/Application/Sources/CarPlay/CarPlayList.swift b/Application/Sources/CarPlay/CarPlayList.swift index 1a03e7346..611ef3c4c 100644 --- a/Application/Sources/CarPlay/CarPlayList.swift +++ b/Application/Sources/CarPlay/CarPlayList.swift @@ -5,8 +5,8 @@ // import CarPlay -import SRGDataProviderCombine import Nuke +import SRGDataProviderCombine // MARK: Types @@ -16,84 +16,84 @@ enum CarPlayList { case mostPopular case mostPopularMedias(radioChannel: RadioChannel) case livePrograms(channel: SRGChannel, media: SRGMedia) - + private static let pageSize: UInt = 20 - + var title: String? { switch self { case .latestEpisodesFromFavorites: - return NSLocalizedString("Favorites", comment: "Tab title to present the latest episodes from favorite shows on CarPlay") + NSLocalizedString("Favorites", comment: "Tab title to present the latest episodes from favorite shows on CarPlay") case .livestreams: - return NSLocalizedString("Livestreams", comment: "Tab title to present the livestreams on CarPlay") + NSLocalizedString("Livestreams", comment: "Tab title to present the livestreams on CarPlay") case .mostPopular: - return NSLocalizedString("Trends", comment: "Tab title to present the most popular medias by channel on CarPlay") + NSLocalizedString("Trends", comment: "Tab title to present the most popular medias by channel on CarPlay") case let .mostPopularMedias(radioChannel: radioChannel): - return radioChannel.name + radioChannel.name case .livePrograms: - return NSLocalizedString("Previous shows", comment: "Livestream previous programs screen title") + NSLocalizedString("Previous shows", comment: "Livestream previous programs screen title") } } - + var pageViewTitle: String? { switch self { case .latestEpisodesFromFavorites: - return AnalyticsPageTitle.latestEpisodesFromFavorites.rawValue + AnalyticsPageTitle.latestEpisodesFromFavorites.rawValue case .livestreams, .mostPopular: - return AnalyticsPageTitle.home.rawValue + AnalyticsPageTitle.home.rawValue case .mostPopularMedias: - return AnalyticsPageTitle.mostPopular.rawValue + AnalyticsPageTitle.mostPopular.rawValue case .livePrograms: - return AnalyticsPageTitle.livePrograms.rawValue + AnalyticsPageTitle.livePrograms.rawValue } } - + var pageViewType: String? { switch self { case .latestEpisodesFromFavorites, .livePrograms, .mostPopularMedias: - return AnalyticsPageType.detail.rawValue + AnalyticsPageType.detail.rawValue case .livestreams: - return AnalyticsPageType.live.rawValue + AnalyticsPageType.live.rawValue case .mostPopular: - return AnalyticsPageType.overview.rawValue + AnalyticsPageType.overview.rawValue } } - + var pageViewLevels: [String]? { switch self { case .latestEpisodesFromFavorites: - return [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.automobile.rawValue] + [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.automobile.rawValue] case .livestreams: - return [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.automobile.rawValue, AnalyticsPageLevel.live.rawValue] + [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.automobile.rawValue, AnalyticsPageLevel.live.rawValue] case .mostPopular: - return [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.automobile.rawValue, AnalyticsPageLevel.mostPopular.rawValue] + [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.automobile.rawValue, AnalyticsPageLevel.mostPopular.rawValue] case let .mostPopularMedias(radioChannel): - return [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.automobile.rawValue, radioChannel.name] + [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.automobile.rawValue, radioChannel.name] case let .livePrograms(channel, _): - return [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.automobile.rawValue, channel.title] + [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.automobile.rawValue, channel.title] } } - + func publisher(with interfaceController: CPInterfaceController) -> AnyPublisher<[CPListSection], Error> { switch self { case .latestEpisodesFromFavorites: - return Publishers.PublishAndRepeat(onOutputFrom: UserInteractionSignal.favoriteUpdates()) { - return SRGDataProvider.current!.favoritesPublisher(filter: self) + Publishers.PublishAndRepeat(onOutputFrom: UserInteractionSignal.favoriteUpdates()) { + SRGDataProvider.current!.favoritesPublisher(filter: self) .map { SRGDataProvider.current!.latestMediasForShowsPublisher(withUrns: $0.map(\.urn), pageSize: Self.pageSize) } .switchToLatest() } .mapToSections(with: interfaceController) case .livestreams: - return Publishers.PublishAndRepeat(onOutputFrom: ApplicationSignal.settingUpdates(at: \.PlaySRGSettingSelectedLivestreamURNForChannels)) { - return Self.livestreamsSections(for: .all, interfaceController: interfaceController) + Publishers.PublishAndRepeat(onOutputFrom: ApplicationSignal.settingUpdates(at: \.PlaySRGSettingSelectedLivestreamURNForChannels)) { + Self.livestreamsSections(for: .all, interfaceController: interfaceController) } case .mostPopular: - return Self.mostPopular(interfaceController: interfaceController) + Self.mostPopular(interfaceController: interfaceController) case let .mostPopularMedias(radioChannel: radioChannel): - return SRGDataProvider.current!.radioMostPopularMedias(for: ApplicationConfiguration.shared.vendor, channelUid: radioChannel.uid, pageSize: Self.pageSize) + SRGDataProvider.current!.radioMostPopularMedias(for: ApplicationConfiguration.shared.vendor, channelUid: radioChannel.uid, pageSize: Self.pageSize) .mapToSections(with: interfaceController) case let .livePrograms(channel, media): - return Publishers.PublishAndRepeat(onOutputFrom: Timer.publish(every: 30, on: .main, in: .common).autoconnect()) { - return Self.liveProgramsSections(for: channel, media: media, interfaceController: interfaceController) + Publishers.PublishAndRepeat(onOutputFrom: Timer.publish(every: 30, on: .main, in: .common).autoconnect()) { + Self.liveProgramsSections(for: channel, media: media, interfaceController: interfaceController) } } } @@ -104,71 +104,70 @@ private extension CarPlayList { let media: SRGMedia let playing: Bool } - + struct LiveProgramData { let program: SRGProgram let image: UIImage let playing: Bool } - + struct MediaData { let media: SRGMedia let image: UIImage let playing: Bool let progress: Double? } - + static func liveMediaDataPublisher(for media: SRGMedia) -> AnyPublisher { - return playingPublisher(for: media.urn) + playingPublisher(for: media.urn) .map { playing in - return LiveMediaData(media: media, playing: playing) + LiveMediaData(media: media, playing: playing) } .eraseToAnyPublisher() } - + static func liveProgramDataPublisher(for program: SRGProgram) -> AnyPublisher { - return Publishers.CombineLatest( + Publishers.CombineLatest( imagePublisher(for: program), playingPublisher(for: program.mediaURN) ) .map { image, playing in - return LiveProgramData(program: program, image: image, playing: playing) + LiveProgramData(program: program, image: image, playing: playing) } .eraseToAnyPublisher() } - + static func mediaDataPublisher(for media: SRGMedia) -> AnyPublisher { - return Publishers.CombineLatest3( + Publishers.CombineLatest3( imagePublisher(for: media), playingPublisher(for: media.urn), UserDataPublishers.playbackProgressPublisher(for: media) ) .map { image, playing, progress in - return MediaData(media: media, image: image, playing: playing, progress: progress) + MediaData(media: media, image: image, playing: playing, progress: progress) } .eraseToAnyPublisher() } - + private static func playingPublisher(for mediaUrn: String?) -> AnyPublisher { if let mediaUrn { - return nowPlayingMediaPublisher() + nowPlayingMediaPublisher() .map { $0.map(\.urn).contains(mediaUrn) } .eraseToAnyPublisher() - } - else { - return Just(false) + } else { + Just(false) .eraseToAnyPublisher() } } - + private static func imagePublisher(for media: SRGMedia) -> AnyPublisher { - return imagePublisher(for: media.image) + imagePublisher(for: media.image) } - + private static func imagePublisher(for program: SRGProgram) -> AnyPublisher { - return imagePublisher(for: program.image) + imagePublisher(for: program.image) } - + private static func imagePublisher(for image: SRGImage?) -> AnyPublisher { let imageSize = SRGImageSize.small let placeholderImage = UIColor.placeholder.image(ofSize: SRGRecommendedImageCGSize(imageSize, .default)) @@ -178,16 +177,15 @@ private extension CarPlayList { .replaceError(with: placeholderImage) .prepend(placeholderImage) .eraseToAnyPublisher() - } - else { + } else { return Just(placeholderImage) .eraseToAnyPublisher() } } - + private static func nowPlayingMedia(for controller: SRGLetterboxController?) -> [SRGMedia] { guard let controller else { return [] } - + var medias: Set = [] if let mainMedia = controller.play_mainMedia { medias.insert(mainMedia) @@ -197,21 +195,20 @@ private extension CarPlayList { } return Array(medias) } - + private static func nowPlayingMediaPublisher() -> AnyPublisher<[SRGMedia], Never> { - return SRGLetterboxService.shared.publisher(for: \.controller) - .map { controller in + SRGLetterboxService.shared.publisher(for: \.controller) + .map { controller -> AnyPublisher<[SRGMedia], Never> in if let controller { - return NotificationCenter.default.weakPublisher(for: .SRGLetterboxMetadataDidChange, object: controller) + NotificationCenter.default.weakPublisher(for: .SRGLetterboxMetadataDidChange, object: controller) .map { notification in let controller = notification.object as? SRGLetterboxController return nowPlayingMedia(for: controller) } .prepend(nowPlayingMedia(for: controller)) .eraseToAnyPublisher() - } - else { - return Just([]) + } else { + Just([]) .eraseToAnyPublisher() } } @@ -219,25 +216,24 @@ private extension CarPlayList { .removeDuplicates() .eraseToAnyPublisher() } - + private static func liveProgramsPublisher(for channel: SRGChannel, media: SRGMedia) -> AnyPublisher<[SRGProgram], Error> { if let controller = SRGLetterboxService.shared.controller, let dateInterval = controller.play_dateInterval, let segments = controller.mediaComposition?.mainChapter.segments, !segments.isEmpty { - return SRGDataProvider.current!.radioLatestPrograms(for: ApplicationConfiguration.shared.vendor, - channelUid: channel.uid, - livestreamUid: media.uid, - from: nil, to: nil, - pageSize: 50, paginatedBy: nil) - .map { _, programs in - return programs - .filter { $0.startDate >= dateInterval.start && $0.startDate <= dateInterval.end } - .reversed() - } - .eraseToAnyPublisher() - } - else { - return Just([]) + SRGDataProvider.current!.radioLatestPrograms(for: ApplicationConfiguration.shared.vendor, + channelUid: channel.uid, + livestreamUid: media.uid, + from: nil, to: nil, + pageSize: 50, paginatedBy: nil) + .map { _, programs in + programs + .filter { $0.startDate >= dateInterval.start && $0.startDate <= dateInterval.end } + .reversed() + } + .eraseToAnyPublisher() + } else { + Just([]) .setFailureType(to: Error.self) .eraseToAnyPublisher() } @@ -248,11 +244,11 @@ private extension CarPlayList { extension CarPlayList: SectionFiltering { func compatibleShows(_ shows: [SRGShow]) -> [SRGShow] { - return shows.filter { $0.transmission == .radio } + shows.filter { $0.transmission == .radio } } - + func compatibleMedias(_ medias: [SRGMedia]) -> [SRGMedia] { - return medias.filter { $0.mediaType == .audio } + medias.filter { $0.mediaType == .audio } } } @@ -263,16 +259,16 @@ private extension CarPlayList { guard let channel = media.channel, let radioChannel = ApplicationConfiguration.shared.radioChannel(forUid: channel.uid) else { return nil } return logoImage(for: radioChannel) } - + private static func logoImage(for channel: RadioChannel) -> UIImage? { - return RadioChannelLogoImageWithTraitCollection(channel, UITraitCollection(userInterfaceIdiom: .carPlay)) + RadioChannelLogoImageWithTraitCollection(channel, UITraitCollection(userInterfaceIdiom: .carPlay)) } - + static func livestreamsSections(for contentProviders: SRGContentProviders, interfaceController: CPInterfaceController) -> AnyPublisher<[CPListSection], Error> { - return SRGDataProvider.current!.regionalizedRadioLivestreams(for: ApplicationConfiguration.shared.vendor, contentProviders: contentProviders) + SRGDataProvider.current!.regionalizedRadioLivestreams(for: ApplicationConfiguration.shared.vendor, contentProviders: contentProviders) .map { medias in - return Publishers.AccumulateLatestMany(medias.map { media in - return liveMediaDataPublisher(for: media) + Publishers.AccumulateLatestMany(medias.map { media in + liveMediaDataPublisher(for: media) }) } .switchToLatest() @@ -291,14 +287,13 @@ private extension CarPlayList { } .eraseToAnyPublisher() } - + static func mostPopular(interfaceController: CPInterfaceController) -> AnyPublisher<[CPListSection], Error> { let radioChannels = ApplicationConfiguration.shared.radioHomepageChannels if radioChannels.count == 1, let radioChannel = radioChannels.first { return SRGDataProvider.current!.radioMostPopularMedias(for: ApplicationConfiguration.shared.vendor, channelUid: radioChannel.uid) .mapToSections(with: interfaceController) - } - else { + } else { return radioChannels.publisher .map { radioChannel in let item = CPListItem(text: radioChannel.name, detailText: nil, image: logoImage(for: radioChannel)) @@ -317,12 +312,12 @@ private extension CarPlayList { .eraseToAnyPublisher() } } - - static func liveProgramsSections(for channel: SRGChannel, media: SRGMedia, interfaceController: CPInterfaceController) -> AnyPublisher<[CPListSection], Error> { - return liveProgramsPublisher(for: channel, media: media) + + static func liveProgramsSections(for channel: SRGChannel, media: SRGMedia, interfaceController _: CPInterfaceController) -> AnyPublisher<[CPListSection], Error> { + liveProgramsPublisher(for: channel, media: media) .map { programs in - return Publishers.AccumulateLatestMany(programs.map { program in - return liveProgramDataPublisher(for: program) + Publishers.AccumulateLatestMany(programs.map { program in + liveProgramDataPublisher(for: program) }) } .switchToLatest() @@ -337,8 +332,7 @@ private extension CarPlayList { SRGLetterboxService.shared.controller?.switch(toURN: mediaUrn, withCompletionHandler: { _ in completion() }) - } - else { + } else { completion() } } @@ -354,9 +348,9 @@ private extension CarPlayList { private extension Publisher where Output == [SRGMedia] { func mapToSections(with interfaceController: CPInterfaceController) -> AnyPublisher<[CPListSection], Failure> { - return map { medias in - return Publishers.AccumulateLatestMany(medias.map { media in - return CarPlayList.mediaDataPublisher(for: media) + map { medias in + Publishers.AccumulateLatestMany(medias.map { media in + CarPlayList.mediaDataPublisher(for: media) }) } .switchToLatest() diff --git a/Application/Sources/CarPlay/CarPlayNowPlayingController.swift b/Application/Sources/CarPlay/CarPlayNowPlayingController.swift index f618f34e8..d8edbfb85 100644 --- a/Application/Sources/CarPlay/CarPlayNowPlayingController.swift +++ b/Application/Sources/CarPlay/CarPlayNowPlayingController.swift @@ -14,10 +14,10 @@ final class CarPlayNowPlayingController: NSObject { private weak var interfaceController: CPInterfaceController? private var popToRootCancellable: AnyCancellable private var nowPlayingPropertiesCancellable: AnyCancellable? - + init(interfaceController: CPInterfaceController) { self.interfaceController = interfaceController - + // If the player is closed on the iOS device return to the first level. A better result would inspect the // template hierarchy to pop to the previous one but this might perform an IPC call. Popping to the root // should be sufficient. @@ -26,7 +26,7 @@ final class CarPlayNowPlayingController: NSObject { .sink { [weak interfaceController] _ in interfaceController?.popToRootTemplate(animated: true) { _, _ in } } - + CPNowPlayingTemplate.shared.upNextTitle = NSLocalizedString("Previous shows", comment: "Button title on CarPlay player for livestream previous programs") } } @@ -35,33 +35,33 @@ private extension CarPlayNowPlayingController { private struct NowPlayingProperties: Equatable { let nowPlayingButtons: [CPNowPlayingButton] let upNextButtonEnabled: Bool - + init(for controller: SRGLetterboxController?, interfaceController: CPInterfaceController) { nowPlayingButtons = Self.nowPlayingButtons(for: controller, interfaceController: interfaceController) upNextButtonEnabled = Self.upNextButtonEnabled(for: controller) } - + private static func playbackRateButton(for interfaceController: CPInterfaceController) -> CPNowPlayingButton { - return CPNowPlayingImageButton(image: UIImage(named: "playback_speed", in: nil, compatibleWith: UITraitCollection(userInterfaceIdiom: .carPlay))!) { _ in + CPNowPlayingImageButton(image: UIImage(named: "playback_speed", in: nil, compatibleWith: UITraitCollection(userInterfaceIdiom: .carPlay))!) { _ in interfaceController.pushTemplate(CPListTemplate.playbackRate, animated: true) { _, _ in } } } - + private static func startOverButton() -> CPNowPlayingButton { - return CPNowPlayingImageButton(image: UIImage(named: "start_over", in: nil, compatibleWith: UITraitCollection(userInterfaceIdiom: .carPlay))!) { _ in + CPNowPlayingImageButton(image: UIImage(named: "start_over", in: nil, compatibleWith: UITraitCollection(userInterfaceIdiom: .carPlay))!) { _ in SRGLetterboxService.shared.controller?.startOver() } } - + private static func skipToLiveButton() -> CPNowPlayingButton { - return CPNowPlayingImageButton(image: UIImage(named: "skip_to_live", in: nil, compatibleWith: UITraitCollection(userInterfaceIdiom: .carPlay))!) { _ in + CPNowPlayingImageButton(image: UIImage(named: "skip_to_live", in: nil, compatibleWith: UITraitCollection(userInterfaceIdiom: .carPlay))!) { _ in SRGLetterboxService.shared.controller?.skipToLive() } } - + private static func nowPlayingButtons(for controller: SRGLetterboxController?, interfaceController: CPInterfaceController) -> [CPNowPlayingButton] { - guard let controller = controller else { return [] } - + guard let controller else { return [] } + var nowPlayingButtons = [playbackRateButton(for: interfaceController)] if controller.canStartOver() { nowPlayingButtons.insert(startOverButton(), at: 0) @@ -71,36 +71,34 @@ private extension CarPlayNowPlayingController { } return nowPlayingButtons } - + private static func upNextButtonEnabled(for controller: SRGLetterboxController?) -> Bool { if let mainChapter = controller?.mediaComposition?.mainChapter, mainChapter.contentType == .livestream, let segments = mainChapter.segments { - return !segments.isEmpty - } - else { - return false + !segments.isEmpty + } else { + false } } } private static func nowPlayingPropertiesPublisher(interfaceController: CPInterfaceController) -> AnyPublisher { - return SRGLetterboxService.shared.publisher(for: \.controller) + SRGLetterboxService.shared.publisher(for: \.controller) .map { controller in if let controller { - return Publishers.CombineLatest3( + Publishers.CombineLatest3( controller.mediaPlayerController.publisher(for: \.timeRange), NotificationCenter.default.weakPublisher(for: .SRGLetterboxPlaybackStateDidChange, object: controller), NotificationCenter.default.weakPublisher(for: .SRGLetterboxMetadataDidChange, object: controller) ) .throttle(for: 0.5, scheduler: DispatchQueue.main, latest: true) .map { _ in - return NowPlayingProperties(for: controller, interfaceController: interfaceController) + NowPlayingProperties(for: controller, interfaceController: interfaceController) } .prepend(NowPlayingProperties(for: controller, interfaceController: interfaceController)) .eraseToAnyPublisher() - } - else { - return Just(NowPlayingProperties(for: controller, interfaceController: interfaceController)) + } else { + Just(NowPlayingProperties(for: controller, interfaceController: interfaceController)) .eraseToAnyPublisher() } } @@ -113,7 +111,7 @@ private extension CarPlayNowPlayingController { // MARK: Protocols extension CarPlayNowPlayingController: CarPlayTemplateController { - func willAppear(animated: Bool) { + func willAppear(animated _: Bool) { CPNowPlayingTemplate.shared.add(self) nowPlayingPropertiesCancellable = Self.nowPlayingPropertiesPublisher(interfaceController: interfaceController!) .sink { nowPlayingProperties in @@ -122,28 +120,28 @@ extension CarPlayNowPlayingController: CarPlayTemplateController { template.isUpNextButtonEnabled = nowPlayingProperties.upNextButtonEnabled } } - - func didAppear(animated: Bool) { + + func didAppear(animated _: Bool) { SRGAnalyticsTracker.shared.uncheckedTrackPageView( withTitle: AnalyticsPageTitle.player.rawValue, type: AnalyticsPageType.detail.rawValue, levels: [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.automobile.rawValue] ) } - - func willDisappear(animated: Bool) {} - - func didDisappear(animated: Bool) { + + func willDisappear(animated _: Bool) {} + + func didDisappear(animated _: Bool) { nowPlayingPropertiesCancellable = nil CPNowPlayingTemplate.shared.remove(self) } } extension CarPlayNowPlayingController: CPNowPlayingTemplateObserver { - func nowPlayingTemplateUpNextButtonTapped(_ nowPlayingTemplate: CPNowPlayingTemplate) { + func nowPlayingTemplateUpNextButtonTapped(_: CPNowPlayingTemplate) { if let channel = SRGLetterboxService.shared.controller?.channel, let media = SRGLetterboxService.shared.controller?.play_mainMedia, - let interfaceController = interfaceController { + let interfaceController { let template = CPListTemplate.list(.livePrograms(channel: channel, media: media), interfaceController: interfaceController) interfaceController.pushTemplate(template, animated: true) { _, _ in } } diff --git a/Application/Sources/CarPlay/CarPlayPlaybackSpeedController.swift b/Application/Sources/CarPlay/CarPlayPlaybackSpeedController.swift index 1d6f5bffa..92d19b145 100644 --- a/Application/Sources/CarPlay/CarPlayPlaybackSpeedController.swift +++ b/Application/Sources/CarPlay/CarPlayPlaybackSpeedController.swift @@ -12,7 +12,7 @@ import SRGLetterbox final class CarPlayPlaybackSpeedController { private var cancellables = Set() - + private static func sections(template: CPListTemplate?) -> [CPListSection] { guard let controller = SRGLetterboxService.shared.controller else { return [] } let items = controller.supportedPlaybackRates @@ -34,10 +34,10 @@ final class CarPlayPlaybackSpeedController { } return [CPListSection(items: items)] } - + init(template: CPListTemplate) { template.emptyViewSubtitleVariants = [NSLocalizedString("No content", comment: "Default text displayed when no content is available")] - + if let controller = SRGLetterboxService.shared.controller { Self.playbackRateChangeSignal(for: controller) .sink { [weak template] _ in @@ -46,23 +46,22 @@ final class CarPlayPlaybackSpeedController { .store(in: &cancellables) } } - + private static func text(forPlaybackRate playbackRate: Float, controller: SRGLetterboxController) -> String { let effectivePlaybackRate = controller.effectivePlaybackRate - if playbackRate == controller.playbackRate && playbackRate != effectivePlaybackRate { + if playbackRate == controller.playbackRate, playbackRate != effectivePlaybackRate { return String(format: NSLocalizedString("%1$@× (Currently: %2$@×)", comment: "Speed factor with current value if different from desired one"), playbackRate.minimalRepresentation, effectivePlaybackRate.minimalRepresentation) - } - else { + } else { return String(format: NSLocalizedString("%@×", comment: "Speed factor"), playbackRate.minimalRepresentation) } } - + private static func accessoryImage(forPlaybackRate playbackRate: Float, controller: SRGLetterboxController) -> UIImage? { - return playbackRate == controller.playbackRate ? UIImage(systemName: "checkmark") : nil + playbackRate == controller.playbackRate ? UIImage(systemName: "checkmark") : nil } - + private static func playbackRateChangeSignal(for controller: SRGLetterboxController) -> AnyPublisher { - return Publishers.Merge( + Publishers.Merge( controller.publisher(for: \.playbackRate), controller.publisher(for: \.effectivePlaybackRate) ) @@ -74,11 +73,11 @@ final class CarPlayPlaybackSpeedController { // MARK: Protocols extension CarPlayPlaybackSpeedController: CarPlayTemplateController { - func willAppear(animated: Bool) {} - - func didAppear(animated: Bool) {} - - func willDisappear(animated: Bool) {} - - func didDisappear(animated: Bool) {} + func willAppear(animated _: Bool) {} + + func didAppear(animated _: Bool) {} + + func willDisappear(animated _: Bool) {} + + func didDisappear(animated _: Bool) {} } diff --git a/Application/Sources/CarPlay/CarPlaySceneDelegate.swift b/Application/Sources/CarPlay/CarPlaySceneDelegate.swift index 9b2cd3d9c..1b9c3670d 100644 --- a/Application/Sources/CarPlay/CarPlaySceneDelegate.swift +++ b/Application/Sources/CarPlay/CarPlaySceneDelegate.swift @@ -19,34 +19,34 @@ extension CarPlaySceneDelegate: CPTemplateApplicationSceneDelegate { let nowPlayingTemplate = CPNowPlayingTemplate.shared nowPlayingTemplate.controller = CarPlayNowPlayingController(interfaceController: interfaceController) } - - func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene, didConnect interfaceController: CPInterfaceController) { + + func templateApplicationScene(_: CPTemplateApplicationScene, didConnect interfaceController: CPInterfaceController) { interfaceController.delegate = self self.interfaceController = interfaceController - + Self.configureNowPlayingTemplate(for: interfaceController) - + let traitCollection = UITraitCollection(userInterfaceIdiom: .carPlay) var templates = [CPTemplate]() - + let livestreamsTemplate = CPListTemplate.list(.livestreams, interfaceController: interfaceController) livestreamsTemplate.tabImage = UIImage(named: "livestreams_tab", in: nil, compatibleWith: traitCollection) templates.append(livestreamsTemplate) - + let favoriteEpisodesTemplate = CPListTemplate.list(.latestEpisodesFromFavorites, interfaceController: interfaceController) favoriteEpisodesTemplate.tabImage = UIImage(named: "favorites_tab", in: nil, compatibleWith: traitCollection) templates.append(favoriteEpisodesTemplate) - + let mostPopularTemplate = CPListTemplate.list(.mostPopular, interfaceController: interfaceController) mostPopularTemplate.tabImage = UIImage(named: "trends_tab", in: nil, compatibleWith: traitCollection) templates.append(mostPopularTemplate) - + let tabBarTemplate = CPTabBarTemplate(templates: templates) interfaceController.setRootTemplate(tabBarTemplate, animated: true, completion: nil) } - - private func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene, didDisconnect interfaceController: CPInterfaceController) { - self.interfaceController = nil + + private func templateApplicationScene(_: CPTemplateApplicationScene, didDisconnect _: CPInterfaceController) { + interfaceController = nil } } @@ -54,15 +54,15 @@ extension CarPlaySceneDelegate: CPInterfaceControllerDelegate { func templateWillAppear(_ aTemplate: CPTemplate, animated: Bool) { aTemplate.notifyWillAppear(animated: animated) } - + func templateDidAppear(_ aTemplate: CPTemplate, animated: Bool) { aTemplate.notifyDidAppear(animated: animated) } - + func templateWillDisappear(_ aTemplate: CPTemplate, animated: Bool) { aTemplate.notifyWillDisappear(animated: animated) } - + func templateDidDisappear(_ aTemplate: CPTemplate, animated: Bool) { aTemplate.notifyDidDisappear(animated: animated) } diff --git a/Application/Sources/CarPlay/CarPlayTemplateController.swift b/Application/Sources/CarPlay/CarPlayTemplateController.swift index 7983796d6..8da0debe2 100644 --- a/Application/Sources/CarPlay/CarPlayTemplateController.swift +++ b/Application/Sources/CarPlay/CarPlayTemplateController.swift @@ -30,68 +30,68 @@ extension CPTemplate { */ var controller: CarPlayTemplateController? { get { - return objc_getAssociatedObject(self, &controllerKey) as? CarPlayTemplateController + objc_getAssociatedObject(self, &controllerKey) as? CarPlayTemplateController } set { objc_setAssociatedObject(self, &controllerKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } - + private var appearedOnce: Bool { get { - return objc_getAssociatedObject(self, &appearedOnceKey) as? Bool ?? false + objc_getAssociatedObject(self, &appearedOnceKey) as? Bool ?? false } set { objc_setAssociatedObject(self, &appearedOnceKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } - + func notifyWillAppear(animated: Bool) { notifyWillAppear(animated: animated, recursive: false) } - + func notifyDidAppear(animated: Bool) { notifyDidAppear(animated: animated, recursive: false) } - + func notifyWillDisappear(animated: Bool) { notifyWillDisappear(animated: animated, recursive: false) } - + func notifyDidDisappear(animated: Bool) { notifyDidDisappear(animated: animated, recursive: false) } - - fileprivate func notifyWillAppear(animated: Bool, recursive: Bool) { + + private func notifyWillAppear(animated: Bool, recursive: Bool) { if recursive, let container = self as? CarPlayTemplateContainer, let activeChildTemplate = container.activeChildTemplate { activeChildTemplate.notifyWillAppear(animated: animated, recursive: recursive) } - + if !recursive || appearedOnce { controller?.willAppear(animated: animated) } } - - fileprivate func notifyDidAppear(animated: Bool, recursive: Bool) { + + private func notifyDidAppear(animated: Bool, recursive: Bool) { if recursive, let container = self as? CarPlayTemplateContainer, let activeChildTemplate = container.activeChildTemplate { activeChildTemplate.notifyDidAppear(animated: animated, recursive: recursive) } - + if !recursive || appearedOnce { controller?.didAppear(animated: animated) } - + appearedOnce = true } - - fileprivate func notifyWillDisappear(animated: Bool, recursive: Bool) { + + private func notifyWillDisappear(animated: Bool, recursive: Bool) { if recursive, let container = self as? CarPlayTemplateContainer, let activeChildTemplate = container.activeChildTemplate { activeChildTemplate.notifyWillDisappear(animated: animated, recursive: recursive) } controller?.willDisappear(animated: animated) } - - fileprivate func notifyDidDisappear(animated: Bool, recursive: Bool) { + + private func notifyDidDisappear(animated: Bool, recursive: Bool) { if recursive, let container = self as? CarPlayTemplateContainer, let activeChildTemplate = container.activeChildTemplate { activeChildTemplate.notifyDidDisappear(animated: animated, recursive: recursive) } @@ -101,6 +101,6 @@ extension CPTemplate { extension CPTabBarTemplate: CarPlayTemplateContainer { var activeChildTemplate: CPTemplate? { - return selectedTemplate + selectedTemplate } } diff --git a/Application/Sources/CarPlay/CarPlayTemplateListController.swift b/Application/Sources/CarPlay/CarPlayTemplateListController.swift index 1a2a8a803..015e5897e 100644 --- a/Application/Sources/CarPlay/CarPlayTemplateListController.swift +++ b/Application/Sources/CarPlay/CarPlayTemplateListController.swift @@ -13,19 +13,19 @@ import SRGDataProviderCombine final class CarPlayTemplateListController { private let list: CarPlayList private var cancellables = Set() - + private let trigger = Trigger() - + init(list: CarPlayList, template: CPListTemplate, interfaceController: CPInterfaceController) { self.list = list - + template.emptyViewSubtitleVariants = [NSLocalizedString("Loading…", comment: "Default text displayed when loading")] - + Publishers.Publish(onOutputFrom: reloadSignal()) { list.publisher(with: interfaceController) .map { State.loaded(sections: $0) } .catch { error in - return Just(State.failed(error: error)) + Just(State.failed(error: error)) } } .receive(on: DispatchQueue.main) @@ -42,37 +42,37 @@ final class CarPlayTemplateListController { } .store(in: &cancellables) } - + private func reloadSignal() -> AnyPublisher { - return Publishers.Merge( + Publishers.Merge( trigger.signal(activatedBy: TriggerId.reload), ApplicationSignal.wokenUp(.scene(filter: notificationFilter.self)) ) .throttle(for: 0.5, scheduler: DispatchQueue.main, latest: false) .eraseToAnyPublisher() } - + private func notificationFilter(notification: Notification) -> Bool { - return notification.object is CPTemplateApplicationScene + notification.object is CPTemplateApplicationScene } } // MARK: Protocols extension CarPlayTemplateListController: CarPlayTemplateController { - func willAppear(animated: Bool) { + func willAppear(animated _: Bool) { trigger.activate(for: TriggerId.reload) } - - func didAppear(animated: Bool) { + + func didAppear(animated _: Bool) { if let pageViewTitle = list.pageViewTitle, let pageViewType = list.pageViewType { SRGAnalyticsTracker.shared.uncheckedTrackPageView(withTitle: pageViewTitle, type: pageViewType, levels: list.pageViewLevels) } } - - func willDisappear(animated: Bool) {} - - func didDisappear(animated: Bool) {} + + func willDisappear(animated _: Bool) {} + + func didDisappear(animated _: Bool) {} } // MARK: Types @@ -82,7 +82,7 @@ extension CarPlayTemplateListController { case failed(error: Error) case loaded(sections: [CPListSection]) } - + enum TriggerId { case reload } diff --git a/Application/Sources/Configuration/ApplicationConfiguration.h b/Application/Sources/Configuration/ApplicationConfiguration.h index 55d29d076..a343f69ca 100755 --- a/Application/Sources/Configuration/ApplicationConfiguration.h +++ b/Application/Sources/Configuration/ApplicationConfiguration.h @@ -66,11 +66,14 @@ OBJC_EXPORT NSString * const ApplicationConfigurationDidChangeNotification; @property (nonatomic, readonly, copy, nullable) NSString *discoverySubtitleOptionLanguage; @property (nonatomic, readonly, getter=arePosterImagesEnabled) BOOL posterImagesEnabled; +@property (nonatomic, readonly, getter=areSquareImagesEnabled) BOOL squareImagesEnabled; @property (nonatomic, readonly) NSArray *liveHomeSections; // wrap `HomeSection` values @property (nonatomic, readonly) NSInteger minimumSocialViewCount; // minimum value to display social view count +@property (nonatomic, readonly, getter=isAudioContentHomepagePreferred) BOOL audioContentHomepagePreferred; + @property (nonatomic, readonly) NSArray *radioChannels; @property (nonatomic, readonly) NSArray *radioHomepageChannels; // radio channels having a corresponding homepage @property (nonatomic, readonly) NSArray *tvChannels; diff --git a/Application/Sources/Configuration/ApplicationConfiguration.m b/Application/Sources/Configuration/ApplicationConfiguration.m index e9647e683..ad50d2124 100755 --- a/Application/Sources/Configuration/ApplicationConfiguration.m +++ b/Application/Sources/Configuration/ApplicationConfiguration.m @@ -151,11 +151,14 @@ @interface ApplicationConfiguration () @property (nonatomic, copy) NSString *discoverySubtitleOptionLanguage; @property (nonatomic, getter=arePosterImagesEnabled) BOOL posterImagesEnabled; +@property (nonatomic, getter=areSquareImagesEnabled) BOOL squareImagesEnabled; @property (nonatomic) NSArray *liveHomeSections; @property (nonatomic) NSInteger minimumSocialViewCount; +@property (nonatomic, getter=isAudioContentHomepagePreferred) BOOL audioContentHomepagePreferred; + @property (nonatomic) NSArray *radioChannels; @property (nonatomic) NSArray *audioHomeSections; // wrap `HomeSection` values @@ -237,8 +240,8 @@ - (BOOL)isContinuousPlaybackAvailable { #if TARGET_OS_IOS return self.continuousPlaybackBackgroundTransitionDuration != SRGLetterboxContinuousPlaybackDisabled - || self.continuousPlaybackForegroundTransitionDuration != SRGLetterboxContinuousPlaybackDisabled - || self.continuousPlaybackPlayerViewTransitionDuration != SRGLetterboxContinuousPlaybackDisabled; + || self.continuousPlaybackForegroundTransitionDuration != SRGLetterboxContinuousPlaybackDisabled + || self.continuousPlaybackPlayerViewTransitionDuration != SRGLetterboxContinuousPlaybackDisabled; #else return self.continuousPlaybackPlayerViewTransitionDuration != SRGLetterboxContinuousPlaybackDisabled; #endif @@ -266,6 +269,51 @@ - (BOOL)arePosterImagesEnabled #endif } +- (BOOL)areSquareImagesEnabled +{ +#if defined(DEBUG) || defined(NIGHTLY) || defined(BETA) + switch (ApplicationSettingSquareImages()) { + case SettingSquareImagesForced: { + return YES; + break; + } + case SettingSquareImagesIgnored: { + return NO; + break; + } + default: { + return _squareImagesEnabled; + break; + } + } +#else + return _squareImagesEnabled; +#endif +} + +- (BOOL)isAudioContentHomepagePreferred +{ +#if defined(DEBUG) || defined(NIGHTLY) || defined(BETA) + switch (ApplicationSettingAudioHomepageOption()) { + case SettingAudioHomepageOptionCuratedOne: { + return YES; + break; + } + case SettingAudioHomepageOptionCuratedMany: + case SettingAudioHomepageOptionPredefinedMany: { + return NO; + break; + } + default: { + return _audioContentHomepagePreferred; + break; + } + } +#else + return _audioContentHomepagePreferred; +#endif +} + #if defined(DEBUG) || defined(NIGHTLY) || defined(BETA) - (void)setOverridePlayURLForVendorBasedOnServiceURL:(NSURL *)serviceURL { @@ -328,7 +376,7 @@ - (BOOL)synchronizeWithFirebaseConfiguration:(PlayFirebaseConfiguration *)fireba if (! analyticsBusinessUnitIdentifier) { return NO; } - + #if defined(DEBUG) || defined(NIGHTLY) || defined(BETA) NSString *analyticsSourceKey = @"39ae8f94-595c-4ca4-81f7-fb7748bd3f04"; #else @@ -421,7 +469,7 @@ - (BOOL)synchronizeWithFirebaseConfiguration:(PlayFirebaseConfiguration *)fireba NSString *betaTestingURLString = [firebaseConfiguration stringForKey:@"betaTestingURL"]; self.betaTestingURL = betaTestingURLString ? [NSURL URLWithString:betaTestingURLString] : nil; - + NSString *sourceCodeURLString = [firebaseConfiguration stringForKey:@"sourceCodeURL"]; self.sourceCodeURL = sourceCodeURLString ? [NSURL URLWithString:sourceCodeURLString] : nil; @@ -447,6 +495,7 @@ - (BOOL)synchronizeWithFirebaseConfiguration:(PlayFirebaseConfiguration *)fireba self.discoverySubtitleOptionLanguage = [firebaseConfiguration stringForKey:@"discoverySubtitleOptionLanguage"]; self.posterImagesEnabled = [firebaseConfiguration boolForKey:@"posterImagesEnabled"]; + self.squareImagesEnabled = [firebaseConfiguration boolForKey:@"squareImagesEnabled"]; #if TARGET_OS_IOS self.liveHomeSections = [firebaseConfiguration homeSectionsForKey:@"liveHomeSections"]; @@ -456,6 +505,10 @@ - (BOOL)synchronizeWithFirebaseConfiguration:(PlayFirebaseConfiguration *)fireba self.audioHomeSections = [firebaseConfiguration homeSectionsForKey:@"audioHomeSections"]; +#if DEBUG || NIGHTLY || BETA + self.audioContentHomepagePreferred = [firebaseConfiguration boolForKey:@"audioContentHomepagePreferred"]; +#endif + self.radioChannels = [firebaseConfiguration radioChannelsForKey:@"radioChannels" defaultHomeSections:self.audioHomeSections]; self.tvChannels = [firebaseConfiguration tvChannelsForKey:@"tvChannels"]; self.satelliteRadioChannels = [firebaseConfiguration radioChannelsForKey:@"satelliteRadioChannels" defaultHomeSections:nil]; diff --git a/Application/Sources/Configuration/ApplicationConfiguration.swift b/Application/Sources/Configuration/ApplicationConfiguration.swift index 9964edd81..28e77102f 100644 --- a/Application/Sources/Configuration/ApplicationConfiguration.swift +++ b/Application/Sources/Configuration/ApplicationConfiguration.swift @@ -10,78 +10,76 @@ extension ApplicationConfiguration { private static func configuredSection(from homeSection: HomeSection) -> ConfiguredSection? { switch homeSection { case .tvLive: - return .tvLive + .tvLive case .radioLive: - return .radioLive + .radioLive case .radioLiveSatellite: - return .radioLiveSatellite + .radioLiveSatellite case .tvLiveCenterScheduledLivestreams: - return .tvLiveCenterScheduledLivestreams + .tvLiveCenterScheduledLivestreams case .tvLiveCenterScheduledLivestreamsAll: - return .tvLiveCenterScheduledLivestreamsAll + .tvLiveCenterScheduledLivestreamsAll case .tvLiveCenterEpisodes: - return .tvLiveCenterEpisodes + .tvLiveCenterEpisodes case .tvLiveCenterEpisodesAll: - return .tvLiveCenterEpisodesAll + .tvLiveCenterEpisodesAll case .tvScheduledLivestreams: - return .tvScheduledLivestreams + .tvScheduledLivestreams case .tvScheduledLivestreamsNews: - return .tvScheduledLivestreamsNews + .tvScheduledLivestreamsNews case .tvScheduledLivestreamsSport: - return .tvScheduledLivestreamsSport + .tvScheduledLivestreamsSport case .tvScheduledLivestreamsSignLanguage: - return .tvScheduledLivestreamsSignLanguage + .tvScheduledLivestreamsSignLanguage default: - return nil + nil } } - + var liveConfiguredSections: [ConfiguredSection] { - return liveHomeSections.compactMap { homeSection in + liveHomeSections.compactMap { homeSection in guard let homeSection = HomeSection(rawValue: homeSection.intValue) else { return nil } return Self.configuredSection(from: homeSection) } } - + var serviceMessageUrl: URL { - return URL(string: "v3/api/\(businessUnitIdentifier)/general-information-message", relativeTo: playServiceURL)! + URL(string: "v3/api/\(businessUnitIdentifier)/general-information-message", relativeTo: playServiceURL)! } - + func relatedContentUrl(for media: SRGMedia) -> URL { - return URL(string: "api/v2/playlist/recommendation/relatedContent/\(media.urn)", relativeTo: self.middlewareURL)! + URL(string: "api/v2/playlist/recommendation/relatedContent/\(media.urn)", relativeTo: middlewareURL)! } - + func topicColors(for topic: SRGTopic) -> (Color, Color)? { - guard let topicColorsArray = self.topicColors[topic.urn], topicColorsArray.count == 2 else { return nil } - + guard let topicColorsArray = topicColors[topic.urn], topicColorsArray.count == 2 else { return nil } + let colors = topicColorsArray.map { Color($0) } return (colors.first!, colors.last!) } - + private static var version: String { - return Bundle.main.play_friendlyVersionNumber + Bundle.main.play_friendlyVersionNumber } - + private static var type: String { if ProcessInfo.processInfo.isMacCatalystApp || ProcessInfo.processInfo.isiOSAppOnMac { - return "desktop" - } - else if UIDevice.current.userInterfaceIdiom == .pad { - return "tablet" - } - else { - return "phone" + "desktop" + } else if UIDevice.current.userInterfaceIdiom == .pad { + "tablet" + } else { + "phone" } } - + private static var identifier: String? { - return UserDefaults.standard.string(forKey: "tc_unique_id") + UserDefaults.standard.string(forKey: "tc_unique_id") } - + private static func typeformUrlWithParameters(_ url: URL) -> URL { guard var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url } guard let host = urlComponents.host, host.contains("typeform.") else { return url } - + let typeformQueryItems = [ URLQueryItem(name: "platform", value: "iOS"), URLQueryItem(name: "version", value: version), @@ -90,36 +88,35 @@ extension ApplicationConfiguration { ] if let queryItems = urlComponents.queryItems { urlComponents.queryItems = typeformQueryItems.appending(contentsOf: queryItems) - } - else { + } else { urlComponents.queryItems = typeformQueryItems } return urlComponents.url ?? url } - + var userSuggestionUrlWithParameters: URL? { guard let feedbackUrl = feedbackURL else { return nil } - + return Self.typeformUrlWithParameters(feedbackUrl) } - + var tvGuideOtherBouquets: [TVGuideBouquet] { - return self.tvGuideOtherBouquetsObjc.map { number in - return TVGuideBouquet(rawValue: number.intValue)! + tvGuideOtherBouquetsObjc.map { number in + TVGuideBouquet(rawValue: number.intValue)! } } } enum ConfiguredSection: Hashable { case availableEpisodes(SRGShow) - - case favoriteShows + + case favoriteShows(contentType: ContentType) case history case watchLater - + case tvAllShows case tvEpisodesForDay(_ day: SRGDay) - + case radioAllShows(channelUid: String) case radioEpisodesForDay(_ day: SRGDay, channelUid: String) case radioFavoriteShows(channelUid: String) @@ -130,11 +127,11 @@ enum ConfiguredSection: Hashable { case radioMostPopular(channelUid: String) case radioResumePlayback(channelUid: String) case radioWatchLater(channelUid: String) - + case tvLive case radioLive case radioLiveSatellite - + case tvLiveCenterScheduledLivestreams case tvLiveCenterScheduledLivestreamsAll case tvLiveCenterEpisodes @@ -143,10 +140,10 @@ enum ConfiguredSection: Hashable { case tvScheduledLivestreamsNews case tvScheduledLivestreamsSport case tvScheduledLivestreamsSignLanguage - -#if os(iOS) - case downloads - case notifications - case radioShowAccess(channelUid: String) -#endif + + #if os(iOS) + case downloads + case notifications + case radioShowAccess(channelUid: String) + #endif } diff --git a/Application/Sources/Configuration/Channel.h b/Application/Sources/Configuration/Channel.h index cbc415b65..b07494feb 100644 --- a/Application/Sources/Configuration/Channel.h +++ b/Application/Sources/Configuration/Channel.h @@ -78,6 +78,11 @@ typedef NS_ENUM(NSInteger, SongsViewStyle) { */ @property (nonatomic, readonly) SongsViewStyle songsViewStyle; +/** + * The channel content page identifier. + */ +@property (nonatomic, readonly, copy, nullable) NSString *contentPageId; + @end NS_ASSUME_NONNULL_END diff --git a/Application/Sources/Configuration/Channel.m b/Application/Sources/Configuration/Channel.m index c87784aa4..ab1d7b138 100644 --- a/Application/Sources/Configuration/Channel.m +++ b/Application/Sources/Configuration/Channel.m @@ -31,6 +31,7 @@ @interface Channel () @property (nonatomic) UIColor *titleColor; @property (nonatomic, getter=hasDarkStatusBar) BOOL darkStatusBar; @property (nonatomic) SongsViewStyle songsViewStyle; +@property (nonatomic, copy) NSString *contentPageId; @end @@ -91,6 +92,13 @@ - (instancetype)initWithDictionary:(NSDictionary *)dictionary if ([songsViewStyleValue isKindOfClass:NSString.class]) { self.songsViewStyle = SongsViewStyleWithString(songsViewStyleValue); } + +#if DEBUG || NIGHTLY || BETA + id contentPageIdValue = dictionary[@"contentPageId"]; + if ([contentPageIdValue isKindOfClass:NSString.class]) { + self.contentPageId = contentPageIdValue; + } +#endif } return self; } diff --git a/Application/Sources/Configuration/PlayFirebaseConfiguration.m b/Application/Sources/Configuration/PlayFirebaseConfiguration.m index 8de93143c..4e3b956ea 100644 --- a/Application/Sources/Configuration/PlayFirebaseConfiguration.m +++ b/Application/Sources/Configuration/PlayFirebaseConfiguration.m @@ -68,9 +68,9 @@ static HomeSection HomeSectionWithString(NSString *string) static NSDictionary *s_bouqets; dispatch_once(&s_onceToken, ^{ s_bouqets = @{ @"thirdparty" : @(TVGuideBouquetThirdParty), - @"rsi" : @(TVGuideBouquetRSI), - @"rts" : @(TVGuideBouquetRTS), - @"srf" : @(TVGuideBouquetSRF) + @"rsi" : @(TVGuideBouquetRSI), + @"rts" : @(TVGuideBouquetRTS), + @"srf" : @(TVGuideBouquetSRF) }; }); return s_bouqets[string]; diff --git a/Application/Sources/Configuration/RadioChannel.swift b/Application/Sources/Configuration/RadioChannel.swift index 18ec0a26e..772079441 100644 --- a/Application/Sources/Configuration/RadioChannel.swift +++ b/Application/Sources/Configuration/RadioChannel.swift @@ -25,17 +25,17 @@ extension RadioChannel { return .radioResumePlayback(channelUid: channelUid) case .radioWatchLater: return .radioWatchLater(channelUid: channelUid) -#if os(iOS) - case .radioShowsAccess: - return .radioShowAccess(channelUid: channelUid) -#endif + #if os(iOS) + case .radioShowsAccess: + return .radioShowAccess(channelUid: channelUid) + #endif default: return nil } } - + func configuredSections() -> [ConfiguredSection] { - return homeSections.compactMap { homeSection in + homeSections.compactMap { homeSection in guard let homeSection = HomeSection(rawValue: homeSection.intValue) else { return nil } return Self.configuredSection(from: homeSection, withChannelUid: uid) } diff --git a/Application/Sources/Content/Content.swift b/Application/Sources/Content/Content.swift index 7d61943b0..f9aab485d 100644 --- a/Application/Sources/Content/Content.swift +++ b/Application/Sources/Content/Content.swift @@ -9,44 +9,63 @@ import SRGDataProviderCombine private let kDefaultNumberOfPlaceholders = 10 private let kDefaultNumberOfLivestreamPlaceholders = 4 +// MARK: Content types + +@objc enum ContentType: Int { + case videoOrTV + case audioOrRadio + case mixed + + var imageVariant: SRGImageVariant { + switch self { + case .videoOrTV: + ApplicationConfiguration.shared.arePosterImagesEnabled ? .poster : .default + case .audioOrRadio: + ApplicationConfiguration.shared.areSquareImagesEnabled ? .podcast : .default + case .mixed: + .default + } + } +} + // MARK: Types enum Content { enum Section: Hashable { - case content(SRGContentSection, show: SRGShow? = nil) + case content(SRGContentSection, type: ContentType, show: SRGShow? = nil) case configured(ConfiguredSection) - + var properties: SectionProperties { switch self { - case let .content(section, show): - return ContentSectionProperties(contentSection: section, show: show) + case let .content(section, type, show): + ContentSectionProperties(contentSection: section, contentType: type, show: show) case let .configured(section): - return ConfiguredSectionProperties(configuredSection: section) + ConfiguredSectionProperties(configuredSection: section) } } } - + indirect enum Item: Hashable { case mediaPlaceholder(index: Int) case media(_ media: SRGMedia) - + case showPlaceholder(index: Int) case show(_ show: SRGShow) - + case topicPlaceholder(index: Int) case topic(_ topic: SRGTopic) - -#if os(iOS) - case download(_ download: Download) - case notification(_ notification: UserNotification) - case showAccess(radioChannel: RadioChannel?) -#endif - + + #if os(iOS) + case download(_ download: Download) + case notification(_ notification: UserNotification) + case showAccess(radioChannel: RadioChannel?) + #endif + case highlightPlaceholder(index: Int) case highlight(_ highlight: Highlight, item: Self?) - + case transparent - + private var title: String? { switch self { case let .media(media): @@ -55,66 +74,62 @@ enum Content { return show.title case let .topic(topic): return topic.title -#if os(iOS) - case let .download(download): - return download.title - case let .notification(notification): - return notification.title -#endif + #if os(iOS) + case let .download(download): + return download.title + case let .notification(notification): + return notification.title + #endif case let .highlight(highlight, _): return highlight.title default: return nil } } - + static func groupAlphabetically(_ items: [Self]) -> [(key: Character, value: [Self])] { - return items.groupedAlphabetically { $0.title } + items.groupedAlphabetically { $0.title } } } - + static func medias(from items: [Self.Item]) -> [SRGMedia] { - return items.compactMap { item in + items.compactMap { item in if case let .media(media) = item { - return media - } - else { - return nil + media + } else { + nil } } } - -#if os(iOS) - static func downloads(from items: [Self.Item]) -> [Download] { - return items.compactMap { item in - if case let .download(download) = item { - return download - } - else { - return nil + + #if os(iOS) + static func downloads(from items: [Self.Item]) -> [Download] { + items.compactMap { item in + if case let .download(download) = item { + download + } else { + nil + } } } - } - - static func notifications(from items: [Self.Item]) -> [UserNotification] { - return items.compactMap { item in - if case let .notification(notification) = item { - return notification - } - else { - return nil + + static func notifications(from items: [Self.Item]) -> [UserNotification] { + items.compactMap { item in + if case let .notification(notification) = item { + notification + } else { + nil + } } } - } -#endif - + #endif + static func shows(from items: [Self.Item]) -> [SRGShow] { - return items.compactMap { item in + items.compactMap { item in if case let .show(show) = item { - return show - } - else { - return nil + show + } else { + nil } } } @@ -134,41 +149,42 @@ protocol SectionProperties { var label: String? { get } var image: SRGImage? { get } var imageVariant: SRGImageVariant { get } - + /// Properties for section detail display var displaysTitle: Bool { get } var supportsEdition: Bool { get } var emptyType: EmptyContentView.`Type` { get } var hasHighlightedItem: Bool { get } - + var couldHaveHighlightedItem: Bool { get } + var displayedShow: SRGShow? { get } -#if os(iOS) - var sharingItem: SharingItem? { get } - var canResetApplicationBadge: Bool { get } -#endif - + #if os(iOS) + var sharingItem: SharingItem? { get } + var canResetApplicationBadge: Bool { get } + #endif + /// Analytics information var analyticsTitle: String? { get } var analyticsType: String? { get } var analyticsLevels: [String]? { get } func analyticsDeletionHiddenEvent(source: AnalyticsListSource) -> AnalyticsEvent? - + /// Properties for section displayed as a row var rowHighlight: Highlight? { get } var placeholderRowItems: [Content.Item] { get } var displaysRowHeader: Bool { get } var openContentPageId: String? { get } - + /// Publisher providing content for the section. A single result must be delivered upon subscription. Further /// results can be retrieved (if any) using a paginator, one page at a time. func publisher(pageSize: UInt, paginatedBy paginator: Trigger.Signal?, filter: SectionFiltering?) -> AnyPublisher<[Content.Item], Error> - + /// Publisher for interactive updates (addition / removal of items by the user). func interactiveUpdatesPublisher() -> AnyPublisher<[Content.Item], Never> - + /// Signal which can be used to trigger a section reload. func reloadSignal() -> AnyPublisher? - + /// Method to be called for removing the specified items from an editable section. func remove(_ items: [Content.Item]) } @@ -176,189 +192,193 @@ protocol SectionProperties { private extension Content { struct ContentSectionProperties: SectionProperties { let contentSection: SRGContentSection + let contentType: ContentType let show: SRGShow? - + private var presentation: SRGContentPresentation { - return contentSection.presentation + contentSection.presentation } - + var title: String? { if let title = presentation.title { - return title - } - else { + title + } else { switch presentation.type { case .favoriteShows: - return NSLocalizedString("Favorites", comment: "Title label used to present the TV or radio favorite shows") + NSLocalizedString("Favorites", comment: "Title label used to present the TV or radio favorite shows") case .myProgram: - return NSLocalizedString("Latest episodes from your favorites", comment: "Title label used to present the latest episodes from TV favorite shows") + NSLocalizedString("Latest episodes from your favorites", comment: "Title label used to present the latest episodes from TV favorite shows") case .livestreams: - return NSLocalizedString("TV channels", comment: "Title label to present main TV livestreams") + NSLocalizedString("TV channels", comment: "Title label to present main TV livestreams") case .continueWatching: - return NSLocalizedString("Resume playback", comment: "Title label used to present medias whose playback can be resumed") + NSLocalizedString("Resume playback", comment: "Title label used to present medias whose playback can be resumed") case .watchLater: - return NSLocalizedString("Later", comment: "Title Label used to present the video later list") + NSLocalizedString("Later", comment: "Title Label used to present the video later list") case .showAccess: - return NSLocalizedString("Shows", comment: "Title label used to present the TV shows AZ and TV shows by date access buttons") + NSLocalizedString("Shows", comment: "Title label used to present the TV shows AZ and TV shows by date access buttons") case .topicSelector: - return NSLocalizedString("Topics", comment: "Title label used to present the topic list") + NSLocalizedString("Topics", comment: "Title label used to present the topic list") default: - return nil + nil } } } - + var summary: String? { - return presentation.summary + presentation.summary } - + var label: String? { - return presentation.label + presentation.label } - + var image: SRGImage? { - return presentation.image + presentation.image } - + var imageVariant: SRGImageVariant { - guard ApplicationConfiguration.shared.arePosterImagesEnabled else { return .default } switch contentSection.type { case .shows: - return .poster + contentType.imageVariant case .predefined: switch presentation.type { case .favoriteShows: - return .poster + contentType.imageVariant default: - return .default + .default } default: - return .default + .default } } - + var displaysTitle: Bool { switch contentSection.type { case .showAndMedias: - return false + false default: - return true + true } } - + var supportsEdition: Bool { switch contentSection.type { case .predefined: switch presentation.type { case .favoriteShows, .continueWatching, .watchLater: - return true + true default: - return false + false } default: - return false + false } } - + var emptyType: EmptyContentView.`Type` { switch contentSection.type { case .predefined: switch contentSection.presentation.type { case .favoriteShows: - return .favoriteShows + .favoriteShows case .myProgram: - return .episodesFromFavorites + .episodesFromFavorites case .continueWatching: - return .resumePlayback + .resumePlayback case .watchLater: - return .watchLater + .watchLater default: - return .generic + .generic } default: - return .generic + .generic } } - + var hasHighlightedItem: Bool { - return presentation.type == .showPromotion - } - - var displayedShow: SRGShow? { - return show + presentation.type == .showPromotion } -#if os(iOS) - var sharingItem: SharingItem? { - return SharingItem(for: contentSection) + + var couldHaveHighlightedItem: Bool { + presentation.type == .highlight } - - var canResetApplicationBadge: Bool { - return false + + var displayedShow: SRGShow? { + show } -#endif - + + #if os(iOS) + var sharingItem: SharingItem? { + SharingItem(for: contentSection) + } + + var canResetApplicationBadge: Bool { + false + } + #endif + var analyticsTitle: String? { switch contentSection.type { case .medias, .showAndMedias, .shows: - return contentSection.presentation.title ?? contentSection.uid + contentSection.presentation.title ?? contentSection.uid case .predefined: switch presentation.type { case .favoriteShows: - return AnalyticsPageTitle.favorites.rawValue + AnalyticsPageTitle.favorites.rawValue case .myProgram: - return AnalyticsPageTitle.latestEpisodesFromFavorites.rawValue + AnalyticsPageTitle.latestEpisodesFromFavorites.rawValue case .continueWatching: - return AnalyticsPageTitle.resumePlayback.rawValue + AnalyticsPageTitle.resumePlayback.rawValue case .watchLater: - return AnalyticsPageTitle.watchLater.rawValue + AnalyticsPageTitle.watchLater.rawValue case .topicSelector: - return AnalyticsPageTitle.topics.rawValue + AnalyticsPageTitle.topics.rawValue default: - return nil + nil } case .none: - return nil + nil } } - + var analyticsType: String? { switch contentSection.type { case .none: - return nil + nil default: - return AnalyticsPageType.detail.rawValue + AnalyticsPageType.detail.rawValue } } - + var analyticsLevels: [String]? { switch contentSection.type { case .medias, .showAndMedias, .shows: - return [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.video.rawValue, AnalyticsPageLevel.section.rawValue] + [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.video.rawValue, AnalyticsPageLevel.section.rawValue] case .predefined: - return [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.video.rawValue] + [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.video.rawValue] case .none: - return nil + nil } } - + func analyticsDeletionHiddenEvent(source: AnalyticsListSource) -> AnalyticsEvent? { switch presentation.type { case .favoriteShows: - return AnalyticsEvent.favorite(action: .remove, source: source, urn: nil) + AnalyticsEvent.favorite(action: .remove, source: source, urn: nil) case .watchLater: - return AnalyticsEvent.watchLater(action: .remove, source: source, urn: nil) + AnalyticsEvent.watchLater(action: .remove, source: source, urn: nil) case .continueWatching: - return AnalyticsEvent.historyRemove(source: source, urn: nil) + AnalyticsEvent.historyRemove(source: source, urn: nil) default: - return nil + nil } } - + var rowHighlight: Highlight? { guard presentation.type == .highlight || presentation.type == .showPromotion else { return nil } return Highlight(from: contentSection) } - + var placeholderRowItems: [Content.Item] { switch presentation.type { case .mediaElement: @@ -366,43 +386,43 @@ private extension Content { case .showElement: return [.showPlaceholder(index: 0)] case .topicSelector: - return (0.. AnyPublisher<[Content.Item], Error> { let dataProvider = SRGDataProvider.current! - + switch contentSection.type { case .medias: return dataProvider.medias(for: contentSection.vendor, contentSectionUid: contentSection.uid, pageSize: pageSize, paginatedBy: paginator) @@ -451,19 +471,18 @@ private extension Content { return dataProvider.laterPublisher(pageSize: pageSize, paginatedBy: paginator, filter: filter) .map { $0.map { .media($0) } } .eraseToAnyPublisher() -#if os(iOS) - case .showAccess: - return Just([.showAccess(radioChannel: nil)]) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() -#endif + #if os(iOS) + case .showAccess: + return Just([.showAccess(radioChannel: nil)]) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + #endif case .availableEpisodes: if let show { return dataProvider.latestMediasForShow(withUrn: show.urn, pageSize: pageSize, paginatedBy: paginator) .map { $0.map { .media($0) } } .eraseToAnyPublisher() - } - else { + } else { return Just([]) .setFailureType(to: Error.self) .eraseToAnyPublisher() @@ -479,38 +498,38 @@ private extension Content { .eraseToAnyPublisher() } } - + func interactiveUpdatesPublisher() -> AnyPublisher<[Content.Item], Never> { switch contentSection.type { case .predefined: switch contentSection.presentation.type { case .favoriteShows, .myProgram: - return UserInteractionSignal.favoriteUpdates() + UserInteractionSignal.favoriteUpdates() case .continueWatching: - return UserInteractionSignal.historyUpdates() + UserInteractionSignal.historyUpdates() case .watchLater: - return UserInteractionSignal.watchLaterUpdates() + UserInteractionSignal.watchLaterUpdates() default: - return Just([]).eraseToAnyPublisher() + Just([]).eraseToAnyPublisher() } default: - return Just([]).eraseToAnyPublisher() + Just([]).eraseToAnyPublisher() } } - + func reloadSignal() -> AnyPublisher? { switch presentation.type { case .favoriteShows, .myProgram: - return ThrottledSignal.preferenceUpdates() + ThrottledSignal.preferenceUpdates() case .watchLater: - return ThrottledSignal.watchLaterUpdates() + ThrottledSignal.watchLaterUpdates() default: // TODO: No history updates yet for battery consumption reasons. Fix when an efficient way to // broadcast and apply history updates is available. - return nil + nil } } - + func remove(_ items: [Content.Item]) { switch presentation.type { case .favoriteShows: @@ -523,17 +542,15 @@ private extension Content { break } } - + private func filterItems(_ items: [T]) -> [T] { guard presentation.type == .mediaElement || presentation.type == .showElement else { return items } - + if presentation.isRandomized, let item = items.randomElement() { return [item] - } - else if !presentation.isRandomized, let item = items.first { + } else if !presentation.isRandomized, let item = items.first { return [item] - } - else { + } else { return [] } } @@ -545,7 +562,7 @@ private extension Content { private extension Content { struct ConfiguredSectionProperties: SectionProperties { let configuredSection: ConfiguredSection - + var title: String? { switch configuredSection { case .history: @@ -586,58 +603,59 @@ private extension Content { return NSLocalizedString("Sport livestreams", comment: "Title label used to present sport scheduled livestream medias") case .tvScheduledLivestreamsSignLanguage: return NSLocalizedString("Sign language livestreams", comment: "Title label used to present sign language scheduled livestream medias") -#if os(iOS) - case .downloads: - return NSLocalizedString("Downloads", comment: "Label to present downloads") - case .notifications: - return NSLocalizedString("Notifications", comment: "Title label used to present notifications") - case .radioShowAccess: - return NSLocalizedString("Shows", comment: "Title label used to present the radio shows AZ and radio shows by date access buttons") -#endif + #if os(iOS) + case .downloads: + return NSLocalizedString("Downloads", comment: "Label to present downloads") + case .notifications: + return NSLocalizedString("Notifications", comment: "Title label used to present notifications") + case .radioShowAccess: + return NSLocalizedString("Shows", comment: "Title label used to present the radio shows AZ and radio shows by date access buttons") + #endif default: return nil } } - + var summary: String? { - return nil + nil } - + var label: String? { - return nil + nil } - + var image: SRGImage? { - return nil + nil } - + var imageVariant: SRGImageVariant { - guard ApplicationConfiguration.shared.arePosterImagesEnabled else { return .default } switch configuredSection { case .tvAllShows: - return .poster + .default + case .radioAllShows: + ContentType.audioOrRadio.imageVariant default: - return .default + ContentType.mixed.imageVariant } } - + var displaysTitle: Bool { - return true + true } - + var supportsEdition: Bool { switch configuredSection { case .favoriteShows, .history, .radioFavoriteShows, .radioResumePlayback, .radioWatchLater, .watchLater: return true -#if os(iOS) - case .downloads, .notifications: - return true -#endif + #if os(iOS) + case .downloads, .notifications: + return true + #endif default: return false } } - + var emptyType: EmptyContentView.`Type` { switch configuredSection { case .favoriteShows, .radioFavoriteShows: @@ -650,49 +668,53 @@ private extension Content { return .history case .radioResumePlayback: return .resumePlayback -#if os(iOS) - case .downloads: - return .downloads - case .notifications: - return .notifications -#endif + #if os(iOS) + case .downloads: + return .downloads + case .notifications: + return .notifications + #endif default: return .generic } } - + var hasHighlightedItem: Bool { - return false + false + } + + var couldHaveHighlightedItem: Bool { + false } - + var displayedShow: SRGShow? { if case let .availableEpisodes(show) = configuredSection { - return show - } - else { - return nil + show + } else { + nil } } -#if os(iOS) - var sharingItem: SharingItem? { - switch configuredSection { - case let .availableEpisodes(show): - return SharingItem(for: show) - default: - return nil + + #if os(iOS) + var sharingItem: SharingItem? { + switch configuredSection { + case let .availableEpisodes(show): + SharingItem(for: show) + default: + nil + } } - } - - var canResetApplicationBadge: Bool { - switch configuredSection { - case .notifications: - return true - default: - return false + + var canResetApplicationBadge: Bool { + switch configuredSection { + case .notifications: + true + default: + false + } } - } -#endif - + #endif + var analyticsTitle: String? { switch configuredSection { case .history: @@ -717,45 +739,44 @@ private extension Content { return AnalyticsPageTitle.sports.rawValue case .tvScheduledLivestreams, .tvScheduledLivestreamsNews, .tvScheduledLivestreamsSport, .tvScheduledLivestreamsSignLanguage: return AnalyticsPageTitle.scheduledLivestreams.rawValue -#if os(iOS) - case .downloads: - return AnalyticsPageTitle.downloads.rawValue - case .notifications: - return AnalyticsPageTitle.notifications.rawValue -#endif + #if os(iOS) + case .downloads: + return AnalyticsPageTitle.downloads.rawValue + case .notifications: + return AnalyticsPageTitle.notifications.rawValue + #endif default: return nil } } - + var analyticsType: String? { switch configuredSection { case .radioAllShows, .tvAllShows: - return AnalyticsPageType.overview.rawValue + AnalyticsPageType.overview.rawValue case .tvLiveCenterScheduledLivestreams, .tvLiveCenterScheduledLivestreamsAll, .tvLiveCenterEpisodes, .tvLiveCenterEpisodesAll, - .tvScheduledLivestreams, .tvScheduledLivestreamsNews, .tvScheduledLivestreamsSport, .tvScheduledLivestreamsSignLanguage, - .tvLive, .radioLive, .radioLiveSatellite: - return AnalyticsPageType.live.rawValue + .tvScheduledLivestreams, .tvScheduledLivestreamsNews, .tvScheduledLivestreamsSport, .tvScheduledLivestreamsSignLanguage, + .tvLive, .radioLive, .radioLiveSatellite: + AnalyticsPageType.live.rawValue default: - return AnalyticsPageType.detail.rawValue + AnalyticsPageType.detail.rawValue } } - + var analyticsLevels: [String]? { switch configuredSection { case let .radioAllShows(channelUid), - let .radioFavoriteShows(channelUid: channelUid), - let .radioLatest(channelUid: channelUid), - let .radioLatestEpisodes(channelUid: channelUid), - let .radioLatestEpisodesFromFavorites(channelUid: channelUid), - let .radioLatestVideos(channelUid: channelUid), - let .radioMostPopular(channelUid: channelUid), - let .radioResumePlayback(channelUid: channelUid), - let .radioWatchLater(channelUid: channelUid): + let .radioFavoriteShows(channelUid: channelUid), + let .radioLatest(channelUid: channelUid), + let .radioLatestEpisodes(channelUid: channelUid), + let .radioLatestEpisodesFromFavorites(channelUid: channelUid), + let .radioLatestVideos(channelUid: channelUid), + let .radioMostPopular(channelUid: channelUid), + let .radioResumePlayback(channelUid: channelUid), + let .radioWatchLater(channelUid: channelUid): if let channel = ApplicationConfiguration.shared.radioChannel(forUid: channelUid) { return [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.audio.rawValue, channel.name] - } - else { + } else { return nil } case .tvAllShows: @@ -774,15 +795,15 @@ private extension Content { return [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.live.rawValue, AnalyticsPageLevel.signLanguage.rawValue] case .favoriteShows, .history, .watchLater: return [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.user.rawValue] -#if os(iOS) - case .downloads, .notifications: - return [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.user.rawValue] -#endif + #if os(iOS) + case .downloads, .notifications: + return [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.user.rawValue] + #endif default: return nil } } - + func analyticsDeletionHiddenEvent(source: AnalyticsListSource) -> AnalyticsEvent? { switch configuredSection { case .favoriteShows, .radioFavoriteShows: @@ -791,52 +812,52 @@ private extension Content { return AnalyticsEvent.watchLater(action: .remove, source: source, urn: nil) case .history, .radioResumePlayback: return AnalyticsEvent.historyRemove(source: source, urn: nil) -#if os(iOS) - case .downloads: - return AnalyticsEvent.download(action: .remove, source: source, urn: nil) -#endif + #if os(iOS) + case .downloads: + return AnalyticsEvent.download(action: .remove, source: source, urn: nil) + #endif default: return nil } } - + var rowHighlight: Highlight? { - return nil + nil } - + var placeholderRowItems: [Content.Item] { switch configuredSection { case .availableEpisodes, .history, .watchLater, .radioEpisodesForDay, .radioLatest, .radioLatestEpisodes, .radioLatestVideos, - .radioMostPopular, .tvEpisodesForDay, .tvLiveCenterScheduledLivestreams, .tvLiveCenterScheduledLivestreamsAll, - .tvLiveCenterEpisodes, .tvLiveCenterEpisodesAll, .tvScheduledLivestreams, .tvScheduledLivestreamsNews, .tvScheduledLivestreamsSport, .tvScheduledLivestreamsSignLanguage: - return (0.. AnyPublisher<[Content.Item], Error> { let dataProvider = SRGDataProvider.current! - + let configuration = ApplicationConfiguration.shared let vendor = configuration.vendor - + switch configuredSection { case let .availableEpisodes(show): return dataProvider.latestMediasForShow(withUrn: show.urn, pageSize: pageSize, paginatedBy: paginator) @@ -948,25 +969,25 @@ private extension Content { return dataProvider.tvScheduledLivestreams(for: vendor, signLanguageOnly: true, pageSize: pageSize, paginatedBy: paginator) .map { $0.map { .media($0) } } .eraseToAnyPublisher() -#if os(iOS) - case .downloads: - return Just(Download.downloads) - .map { $0.map { .download($0) } } - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - case .notifications: - return Just(UserNotification.notifications) - .map { $0.map { .notification($0) } } - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - case let .radioShowAccess(channelUid): - return Just([.showAccess(radioChannel: configuration.radioChannel(forUid: channelUid))]) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() -#endif + #if os(iOS) + case .downloads: + return Just(Download.downloads) + .map { $0.map { .download($0) } } + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + case .notifications: + return Just(UserNotification.notifications) + .map { $0.map { .notification($0) } } + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + case let .radioShowAccess(channelUid): + return Just([.showAccess(radioChannel: configuration.radioChannel(forUid: channelUid))]) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + #endif } } - + func interactiveUpdatesPublisher() -> AnyPublisher<[Content.Item], Never> { switch configuredSection { case .favoriteShows, .radioFavoriteShows, .radioLatestEpisodesFromFavorites: @@ -975,17 +996,17 @@ private extension Content { return UserInteractionSignal.historyUpdates() case .radioWatchLater, .watchLater: return UserInteractionSignal.watchLaterUpdates() -#if os(iOS) - case .downloads: - return UserInteractionSignal.downloadUpdates() - case .notifications: - return UserInteractionSignal.notificationUpdates() -#endif + #if os(iOS) + case .downloads: + return UserInteractionSignal.downloadUpdates() + case .notifications: + return UserInteractionSignal.notificationUpdates() + #endif default: return Just([]).eraseToAnyPublisher() } } - + func reloadSignal() -> AnyPublisher? { switch configuredSection { case .favoriteShows, .radioFavoriteShows, .radioLatestEpisodesFromFavorites: @@ -994,17 +1015,17 @@ private extension Content { return ThrottledSignal.watchLaterUpdates() case .radioLive: return ApplicationSignal.settingUpdates(at: \.PlaySRGSettingSelectedLivestreamURNForChannels) -#if os(iOS) - case .downloads: - return ThrottledSignal.downloadUpdates() -#endif + #if os(iOS) + case .downloads: + return ThrottledSignal.downloadUpdates() + #endif default: // TODO: No history updates yet for battery consumption reasons. Fix when an efficient way to // broadcast and apply history updates is available. return nil } } - + func remove(_ items: [Content.Item]) { switch configuredSection { case .favoriteShows, .radioFavoriteShows: @@ -1013,12 +1034,12 @@ private extension Content { Content.removeFromWatchLater(items) case .history, .radioResumePlayback: Content.removeFromHistory(items) -#if os(iOS) - case .downloads: - Content.removeFromDownloads(items) - case .notifications: - Content.removeFromNotifications(items) -#endif + #if os(iOS) + case .downloads: + Content.removeFromDownloads(items) + case .notifications: + Content.removeFromNotifications(items) + #endif default: break } @@ -1033,30 +1054,30 @@ private extension Content { let shows = Content.shows(from: items) FavoritesRemoveShows(shows) } - + static func removeFromHistory(_ items: [Content.Item]) { let medias = Content.medias(from: items) HistoryRemoveMedias(medias) { _ in } } - -#if os(iOS) - static func removeFromNotifications(_ items: [Content.Item]) { - let notifications = Content.notifications(from: items) - let updatedNotifications = Array(Set(UserNotification.notifications).subtracting(notifications)) - UserNotification.saveNotifications(updatedNotifications) - UserInteractionEvent.removeFromNotifications(notifications) - } -#endif - + + #if os(iOS) + static func removeFromNotifications(_ items: [Content.Item]) { + let notifications = Content.notifications(from: items) + let updatedNotifications = Array(Set(UserNotification.notifications).subtracting(notifications)) + UserNotification.saveNotifications(updatedNotifications) + UserInteractionEvent.removeFromNotifications(notifications) + } + #endif + static func removeFromWatchLater(_ items: [Content.Item]) { let medias = Content.medias(from: items) WatchLaterRemoveMedias(medias) { _ in } } - -#if os(iOS) - static func removeFromDownloads(_ items: [Content.Item]) { - let downloads = Content.downloads(from: items) - Download.removeDownloads(downloads) - } -#endif + + #if os(iOS) + static func removeFromDownloads(_ items: [Content.Item]) { + let downloads = Content.downloads(from: items) + Download.removeDownloads(downloads) + } + #endif } diff --git a/Application/Sources/Content/PageViewController.swift b/Application/Sources/Content/PageViewController.swift index d83fbaf1b..e82a44561 100644 --- a/Application/Sources/Content/PageViewController.swift +++ b/Application/Sources/Content/PageViewController.swift @@ -10,7 +10,7 @@ import SwiftUI import UIKit #if os(iOS) -import GoogleCast + import GoogleCast #endif // MARK: View controller @@ -18,32 +18,32 @@ import GoogleCast final class PageViewController: UIViewController { private let model: PageViewModel private let fromPushNotification: Bool - + private var cancellables = Set() - + private var dataSource: UICollectionViewDiffableDataSource! - + private weak var collectionView: UICollectionView! private weak var emptyContentView: HostView! private weak var topicGradientView: HostView! private weak var topicGradientViewFixTopAnchor: NSLayoutConstraint! private weak var topicGradientViewStickyTopAnchor: NSLayoutConstraint! private weak var topicGradientViewHeightAnchor: NSLayoutConstraint! - -#if os(iOS) - private weak var refreshControl: UIRefreshControl! - private weak var googleCastButton: GoogleCastFloatingButton? - - private var isNavigationBarHidden: Bool { - return model.isNavigationBarHidden && !UIAccessibility.isVoiceOverRunning - } - - private var refreshTriggered = false - private var headerWithTitleVisible = false -#endif - + + #if os(iOS) + private weak var refreshControl: UIRefreshControl! + private weak var googleCastButton: GoogleCastFloatingButton? + + private var isNavigationBarHidden: Bool { + model.isNavigationBarHidden && !UIAccessibility.isVoiceOverRunning + } + + private var refreshTriggered = false + private var headerWithTitleVisible = false + #endif + private var analyticsPageViewTracked = false - + private static func snapshot(from state: PageViewModel.State) -> NSDiffableDataSourceSnapshot { var snapshot = NSDiffableDataSourceSnapshot() if case let .loaded(rows: rows, _) = state { @@ -54,55 +54,53 @@ final class PageViewController: UIViewController { } return snapshot } - -#if os(iOS) - private static func showByDateViewController(radioChannel: RadioChannel?, date: Date?) -> UIViewController { - if let radioChannel { - return CalendarViewController(radioChannel: radioChannel, date: date) - } - else if !ApplicationConfiguration.shared.isTvGuideUnavailable { - return ProgramGuideViewController(date: date) - } - else { - return CalendarViewController(radioChannel: nil, date: date) + + #if os(iOS) + private static func showByDateViewController(radioChannel: RadioChannel?, date: Date?) -> UIViewController { + if let radioChannel { + CalendarViewController(radioChannel: radioChannel, date: date) + } else if !ApplicationConfiguration.shared.isTvGuideUnavailable { + ProgramGuideViewController(date: date) + } else { + CalendarViewController(radioChannel: nil, date: date) + } } - } -#endif - + #endif + init(id: PageViewModel.Id, fromPushNotification: Bool = false) { model = PageViewModel(id: id) self.fromPushNotification = fromPushNotification super.init(nibName: nil, bundle: nil) title = id.title } - - required init?(coder: NSCoder) { + + @available(*, unavailable) + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } - + var displayedShow: SRGShow? { - return model.displayedShow + model.displayedShow } - + @objc var radioChannel: RadioChannel? { if case let .audio(channel: channel) = model.id { - return channel - } - else { - return nil + channel + } else { + nil } } - + override func loadView() { let view = UIView(frame: UIScreen.main.bounds) view.backgroundColor = .srgGray16 - + let collectionView = CollectionView(frame: .zero, collectionViewLayout: layout(for: model)) collectionView.delegate = self collectionView.backgroundColor = .clear view.addSubview(collectionView) self.collectionView = collectionView - + collectionView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ collectionView.topAnchor.constraint(equalTo: view.topAnchor), @@ -110,20 +108,20 @@ final class PageViewController: UIViewController { collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) - -#if os(iOS) - if #available(iOS 17.0, *) { - collectionView.registerForTraitChanges([UITraitHorizontalSizeClass.self]) { (collectionView: CollectionView, _) in - collectionView.collectionViewLayout.invalidateLayout() - - self.updateLayoutConfiguration() - self.updateTopicGradientLayout() + + #if os(iOS) + if #available(iOS 17.0, *) { + collectionView.registerForTraitChanges([UITraitHorizontalSizeClass.self]) { (collectionView: CollectionView, _) in + collectionView.collectionViewLayout.invalidateLayout() + + self.updateLayoutConfiguration() + self.updateTopicGradientLayout() + } } - } -#endif + #endif let backgroundView = UIView(frame: .zero) collectionView.backgroundView = backgroundView - + backgroundView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ backgroundView.topAnchor.constraint(equalTo: view.topAnchor), @@ -131,17 +129,17 @@ final class PageViewController: UIViewController { backgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor), backgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) - + let topicGradientView = HostView(frame: .zero) backgroundView.addSubview(topicGradientView) self.topicGradientView = topicGradientView - + topicGradientView.translatesAutoresizingMaskIntoConstraints = false - let topicGradientViewFixTopAnchor = topicGradientView.topAnchor.constraint(equalTo: backgroundView.topAnchor, constant: 0 /* set in updateTopicGradientLayout() */) + let topicGradientViewFixTopAnchor = topicGradientView.topAnchor.constraint(equalTo: backgroundView.topAnchor, constant: 0 /* set in updateTopicGradientLayout() */ ) topicGradientViewFixTopAnchor.priority = .defaultHigh - let topicGradientViewStickyTopAnchor = topicGradientView.topAnchor.constraint(equalTo: collectionView.topAnchor, constant: 0 /* set in updateTopicGradientLayout() */) + let topicGradientViewStickyTopAnchor = topicGradientView.topAnchor.constraint(equalTo: collectionView.topAnchor, constant: 0 /* set in updateTopicGradientLayout() */ ) topicGradientViewStickyTopAnchor.priority = .defaultLow - let topicGradientViewHeightAnchor = topicGradientView.heightAnchor.constraint(equalToConstant: 0 /* set in updateTopicGradientLayout() */) + let topicGradientViewHeightAnchor = topicGradientView.heightAnchor.constraint(equalToConstant: 0 /* set in updateTopicGradientLayout() */ ) NSLayoutConstraint.activate([ topicGradientViewFixTopAnchor, topicGradientViewStickyTopAnchor, @@ -152,11 +150,11 @@ final class PageViewController: UIViewController { self.topicGradientViewFixTopAnchor = topicGradientViewFixTopAnchor self.topicGradientViewStickyTopAnchor = topicGradientViewStickyTopAnchor self.topicGradientViewHeightAnchor = topicGradientViewHeightAnchor - + let emptyContentView = HostView(frame: .zero) backgroundView.addSubview(emptyContentView) self.emptyContentView = emptyContentView - + emptyContentView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ emptyContentView.topAnchor.constraint(equalTo: backgroundView.topAnchor), @@ -164,70 +162,68 @@ final class PageViewController: UIViewController { emptyContentView.leadingAnchor.constraint(equalTo: backgroundView.leadingAnchor), emptyContentView.trailingAnchor.constraint(equalTo: backgroundView.trailingAnchor) ]) - -#if os(tvOS) - tabBarObservedScrollView = collectionView -#else - let refreshControl = RefreshControl() - refreshControl.addTarget(self, action: #selector(pullToRefresh), for: .valueChanged) - collectionView.insertSubview(refreshControl, at: 0) - self.refreshControl = refreshControl -#endif + + #if os(tvOS) + tabBarObservedScrollView = collectionView + #else + let refreshControl = RefreshControl() + refreshControl.addTarget(self, action: #selector(pullToRefresh), for: .valueChanged) + collectionView.insertSubview(refreshControl, at: 0) + self.refreshControl = refreshControl + #endif self.view = view } - + override func viewDidLoad() { super.viewDidLoad() - -#if os(iOS) - navigationItem.largeTitleDisplayMode = model.isLargeTitleDisplayMode ? .always : .never - headerWithTitleVisible = model.isHeaderWithTitle -#endif - + + #if os(iOS) + navigationItem.largeTitleDisplayMode = model.isLargeTitleDisplayMode ? .always : .never + headerWithTitleVisible = model.isHeaderWithTitle + #endif + let cellRegistration = UICollectionView.CellRegistration, PageViewModel.Item> { [model] cell, _, item in cell.content = ItemCell(item: item, id: model.id, primaryColor: model.primaryColor, secondaryColor: model.secondaryColor) } - + dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item in - return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item) + collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item) } - + let titleHeaderViewRegistration = UICollectionView.SupplementaryRegistration>(elementKind: Header.titleHeader.rawValue) { [weak self] view, _, _ in guard let self else { return } view.content = TitleHeaderView(model.displayedTitle, description: model.displayedTitleDescription, titleTextAlignment: model.displayedTitleTextAlignment, topPadding: Self.layoutDisplayedTitleTopPadding(model.displayedTitleNeedsTopPadding)).primaryColor(model.primaryColor) } - + let showHeaderViewRegistration = UICollectionView.SupplementaryRegistration>(elementKind: Header.showHeader.rawValue) { [weak self] view, _, _ in guard let self else { return } view.content = ShowHeaderView(model.displayedShow, horizontalPadding: Self.layoutHorizontalMargin).primaryColor(model.primaryColor) } - + let sectionHeaderViewRegistration = UICollectionView.SupplementaryRegistration>(elementKind: UICollectionView.elementKindSectionHeader) { [weak self] view, _, indexPath in guard let self else { return } let snapshot = dataSource.snapshot() let section = snapshot.sectionIdentifiers[indexPath.section] view.content = SectionHeaderView(section: section, pageId: model.id).primaryColor(model.primaryColor) } - + dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in if kind == Header.titleHeader.rawValue { - return collectionView.dequeueConfiguredReusableSupplementary(using: titleHeaderViewRegistration, for: indexPath) - } - else if kind == Header.showHeader.rawValue { - return collectionView.dequeueConfiguredReusableSupplementary(using: showHeaderViewRegistration, for: indexPath) - } - else { - return collectionView.dequeueConfiguredReusableSupplementary(using: sectionHeaderViewRegistration, for: indexPath) + collectionView.dequeueConfiguredReusableSupplementary(using: titleHeaderViewRegistration, for: indexPath) + } else if kind == Header.showHeader.rawValue { + collectionView.dequeueConfiguredReusableSupplementary(using: showHeaderViewRegistration, for: indexPath) + } else { + collectionView.dequeueConfiguredReusableSupplementary(using: sectionHeaderViewRegistration, for: indexPath) } } - + model.$state .sink { [weak self] state in self?.trackPageView(state: state) self?.reloadData(for: state) } .store(in: &cancellables) - + model.$displayedShow .dropFirst() .sink { [weak self] _ in @@ -240,63 +236,63 @@ final class PageViewController: UIViewController { } } .store(in: &cancellables) - -#if os(iOS) - model.$serviceMessage - .sink { serviceMessage in - guard let serviceMessage else { return } - Banner.show(with: .error, message: serviceMessage.text, image: nil, sticky: true) - } - .store(in: &cancellables) - - NotificationCenter.default.weakPublisher(for: UIAccessibility.voiceOverStatusDidChangeNotification) - .sink { [weak self] _ in - guard let self, play_isViewCurrent else { return } - updateNavigationBar(animated: true) - } - .store(in: &cancellables) -#endif + + #if os(iOS) + model.$serviceMessage + .sink { serviceMessage in + guard let serviceMessage else { return } + Banner.show(with: .error, message: serviceMessage.text, image: nil, sticky: true) + } + .store(in: &cancellables) + + NotificationCenter.default.weakPublisher(for: UIAccessibility.voiceOverStatusDidChangeNotification) + .sink { [weak self] _ in + guard let self, play_isViewCurrent else { return } + updateNavigationBar(animated: true) + } + .store(in: &cancellables) + #endif } - + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - + updateLayoutConfiguration() updateTopicGradientLayout() model.reload() deselectItems(in: collectionView, animated: animated) -#if os(iOS) - updateNavigationBar(animated: animated) -#endif + #if os(iOS) + updateNavigationBar(animated: animated) + #endif userActivity = model.userActivity } - + override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) userActivity = nil } - + override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - + updateLayoutConfiguration() updateTopicGradientLayout() } - + private func updateLayoutConfiguration() { // Update configuration supplementary views layouts (ie: show header layout). // Update configuration forces a collection view layout refresh. Updating only configuration.boundarySupplementaryItems does not. - if let collectionViewLayout = self.collectionView.collectionViewLayout as? UICollectionViewCompositionalLayout { + if let collectionViewLayout = collectionView.collectionViewLayout as? UICollectionViewCompositionalLayout { collectionViewLayout.configuration = Self.layoutConfiguration(model: model, layoutWidth: view.safeAreaLayoutGuide.layoutFrame.width, horizontalSizeClass: view.traitCollection.horizontalSizeClass, offsetX: view.safeAreaLayoutGuide.layoutFrame.minX) } } - + private func emptyViewEdgeInsets() -> EdgeInsets { let configuration = Self.layoutConfiguration(model: model, layoutWidth: view.safeAreaLayoutGuide.layoutFrame.width, horizontalSizeClass: view.traitCollection.horizontalSizeClass, offsetX: view.safeAreaLayoutGuide.layoutFrame.minX) - let supplementaryItemsHeight = configuration.boundarySupplementaryItems.map { $0.layoutSize.heightDimension.dimension }.reduce(0, +) + let supplementaryItemsHeight = configuration.boundarySupplementaryItems.map(\.layoutSize.heightDimension.dimension).reduce(0, +) return EdgeInsets(top: supplementaryItemsHeight, leading: 0, bottom: 0, trailing: 0) } - + private func reloadData(for state: PageViewModel.State) { switch state { case .loading: @@ -306,97 +302,93 @@ final class PageViewController: UIViewController { case let .loaded(rows: rows, _): emptyContentView.content = rows.isEmpty ? EmptyContentView(state: .empty(type: .generic), insets: emptyViewEdgeInsets()) : nil } - + if let topic = model.displayedGradientTopic, let style = model.displayedGradientTopicStyle { - self.topicGradientView.content = TopicGradientView(topic, style: style) - } - else { - self.topicGradientView.content = nil + topicGradientView.content = TopicGradientView(topic, style: style) + } else { + topicGradientView.content = nil } - + DispatchQueue.global(qos: .userInteractive).async { // Can be triggered on a background thread. Layout is updated on the main thread. self.dataSource.apply(Self.snapshot(from: state)) { -#if os(iOS) - // Avoid stopping scrolling - // See http://stackoverflow.com/a/31681037/760435 - if self.refreshControl.isRefreshing { - self.refreshControl.endRefreshing() - } -#endif + #if os(iOS) + // Avoid stopping scrolling + // See http://stackoverflow.com/a/31681037/760435 + if self.refreshControl.isRefreshing { + self.refreshControl.endRefreshing() + } + #endif } } } - -#if os(iOS) - private func updateNavigationBar(animated: Bool) { - if model.id.supportsCastButton { - if !isNavigationBarHidden, let navigationBar = navigationController?.navigationBar { - self.googleCastButton?.removeFromSuperview() - navigationItem.rightBarButtonItem = GoogleCastBarButtonItem(for: navigationBar) + + #if os(iOS) + private func updateNavigationBar(animated: Bool) { + if model.id.supportsCastButton { + if !isNavigationBarHidden, let navigationBar = navigationController?.navigationBar { + googleCastButton?.removeFromSuperview() + navigationItem.rightBarButtonItem = GoogleCastBarButtonItem(for: navigationBar) + } else if googleCastButton == nil { + let googleCastButton = GoogleCastFloatingButton(frame: .zero) + view.addSubview(googleCastButton) + self.googleCastButton = googleCastButton + + // Place the button where it would appear if a navigation bar was available. An offset is needed on iPads for a perfect + // result (might be fragile but should be enough). + let topOffset: CGFloat = (UIDevice.current.userInterfaceIdiom == .pad) ? 3 : 0 + NSLayoutConstraint.activate([ + googleCastButton.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: topOffset), + googleCastButton.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor) + ]) + } + } else { + googleCastButton?.removeFromSuperview() } - else if self.googleCastButton == nil { - let googleCastButton = GoogleCastFloatingButton(frame: .zero) - view.addSubview(googleCastButton) - self.googleCastButton = googleCastButton - - // Place the button where it would appear if a navigation bar was available. An offset is needed on iPads for a perfect - // result (might be fragile but should be enough). - let topOffset: CGFloat = (UIDevice.current.userInterfaceIdiom == .pad) ? 3 : 0 - NSLayoutConstraint.activate([ - googleCastButton.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: topOffset), - googleCastButton.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor) - ]) + + navigationItem.title = !headerWithTitleVisible ? title : nil + navigationController?.setNavigationBarHidden(isNavigationBarHidden, animated: animated) + + if model.id.sharingItem != nil { + let shareButtonItem = UIBarButtonItem(image: UIImage(named: "share"), + style: .plain, + target: self, + action: #selector(shareContent(_:))) + shareButtonItem.accessibilityLabel = PlaySRGAccessibilityLocalizedString("Share", comment: "Share button label on content page view") + navigationItem.rightBarButtonItem = shareButtonItem + } else { + navigationItem.rightBarButtonItem = nil } } - else { - self.googleCastButton?.removeFromSuperview() - } - - navigationItem.title = !headerWithTitleVisible ? title : nil - navigationController?.setNavigationBarHidden(isNavigationBarHidden, animated: animated) - - if model.id.sharingItem != nil { - let shareButtonItem = UIBarButtonItem(image: UIImage(named: "share"), - style: .plain, - target: self, - action: #selector(self.shareContent(_:))) - shareButtonItem.accessibilityLabel = PlaySRGAccessibilityLocalizedString("Share", comment: "Share button label on content page view") - navigationItem.rightBarButtonItem = shareButtonItem - } - else { - navigationItem.rightBarButtonItem = nil + + @objc private func pullToRefresh(_ refreshControl: RefreshControl) { + if refreshControl.isRefreshing { + refreshControl.endRefreshing() + } + refreshTriggered = true } - } - - @objc private func pullToRefresh(_ refreshControl: RefreshControl) { - if refreshControl.isRefreshing { - refreshControl.endRefreshing() + + @objc private func shareContent(_ barButtonItem: UIBarButtonItem) { + guard let sharingItem = model.id.sharingItem else { return } + + let activityViewController = UIActivityViewController(sharingItem: sharingItem, from: .button) + activityViewController.modalPresentationStyle = .popover + + let popoverPresentationController = activityViewController.popoverPresentationController + popoverPresentationController?.barButtonItem = barButtonItem + + present(activityViewController, animated: true, completion: nil) } - refreshTriggered = true - } - - @objc private func shareContent(_ barButtonItem: UIBarButtonItem) { - guard let sharingItem = model.id.sharingItem else { return } - - let activityViewController = UIActivityViewController(sharingItem: sharingItem, from: .button) - activityViewController.modalPresentationStyle = .popover - - let popoverPresentationController = activityViewController.popoverPresentationController - popoverPresentationController?.barButtonItem = barButtonItem - - self.present(activityViewController, animated: true, completion: nil) - } -#endif - + #endif + private func trackPageView(state: PageViewModel.State) { switch state { case .loading: break case let .failed(_, pageUid), let .loaded(_, pageUid): - guard !self.analyticsPageViewTracked else { return } - self.analyticsPageViewTracked = true - + guard !analyticsPageViewTracked else { return } + analyticsPageViewTracked = true + SRGAnalyticsTracker.shared.trackPageView(withTitle: model.id.analyticsPageViewTitle, type: model.id.analyticsPageViewType, levels: model.id.analyticsPageViewLevels, @@ -413,39 +405,43 @@ private extension PageViewController { case titleHeader case showHeader } - -#if os(iOS) - private typealias CollectionView = DampedCollectionView -#else - private typealias CollectionView = UICollectionView -#endif + + #if os(iOS) + private typealias CollectionView = DampedCollectionView + #else + private typealias CollectionView = UICollectionView + #endif } // MARK: Objective-C API extension PageViewController { @objc static func videosViewController() -> PageViewController { - return PageViewController(id: .video) + PageViewController(id: .video) } - + + @objc static func audiosViewController() -> PageViewController { + PageViewController(id: .audio(channel: nil)) + } + @objc static func audiosViewController(forRadioChannel channel: RadioChannel) -> PageViewController { - return PageViewController(id: .audio(channel: channel)) + PageViewController(id: .audio(channel: channel)) } - + @objc static func liveViewController() -> PageViewController { - return PageViewController(id: .live) + PageViewController(id: .live) } - + @objc static func topicViewController(for topic: SRGTopic) -> PageViewController { - return PageViewController(id: .topic(topic)) + PageViewController(id: .topic(topic)) } - + @objc static func showViewController(for show: SRGShow, fromPushNotification: Bool = false) -> PageViewController { - return PageViewController(id: .show(show), fromPushNotification: fromPushNotification) + PageViewController(id: .show(show), fromPushNotification: fromPushNotification) } - + @objc static func pageViewController(for page: SRGContentPage) -> PageViewController { - return PageViewController(id: .page(page)) + PageViewController(id: .page(page)) } } @@ -453,279 +449,280 @@ extension PageViewController { extension PageViewController: ContentInsets { var play_contentScrollViews: [UIScrollView]? { - return collectionView != nil ? [collectionView] : nil + collectionView != nil ? [collectionView] : nil } - + var play_paddingContentInsets: UIEdgeInsets { -#if os(iOS) - let top = (isNavigationBarHidden || model.isHeaderWithTitle) ? 0 : Self.layoutVerticalMargin -#else - let top = Self.layoutVerticalMargin -#endif + #if os(iOS) + let top = (isNavigationBarHidden || model.isHeaderWithTitle) ? 0 : Self.layoutVerticalMargin + #else + let top = Self.layoutVerticalMargin + #endif return UIEdgeInsets(top: top, left: 0, bottom: Self.layoutVerticalMargin, right: 0) } } #if os(iOS) -extension PageViewController: Oriented { -} + extension PageViewController: Oriented {} #endif extension PageViewController: ScrollableContent { var play_scrollableView: UIScrollView? { - return collectionView + collectionView } } extension PageViewController: UICollectionViewDelegate { -#if os(iOS) - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - let snapshot = dataSource.snapshot() - let section = snapshot.sectionIdentifiers[indexPath.section] - let item = snapshot.itemIdentifiers(inSection: section)[indexPath.row] - - switch item.wrappedValue { - case let .item(wrappedItem): - switch wrappedItem { - case let .media(media): - play_presentMediaPlayer(with: media, position: nil, airPlaySuggestions: true, fromPushNotification: false, animated: true, completion: nil) - case let .show(show): - if let navigationController { - let pageViewController = PageViewController(id: .show(show)) - navigationController.pushViewController(pageViewController, animated: true) - } - case let .topic(topic): - if let navigationController { - let pageViewController = PageViewController(id: .topic(topic)) - navigationController.pushViewController(pageViewController, animated: true) - } - case let .highlight(_, highlightedItem): - if let navigationController { - if case let .show(show) = highlightedItem { + #if os(iOS) + func collectionView(_: UICollectionView, didSelectItemAt indexPath: IndexPath) { + let snapshot = dataSource.snapshot() + let section = snapshot.sectionIdentifiers[indexPath.section] + let item = snapshot.itemIdentifiers(inSection: section)[indexPath.row] + + switch item.wrappedValue { + case let .item(wrappedItem): + switch wrappedItem { + case let .media(media): + play_presentMediaPlayer(with: media, position: nil, airPlaySuggestions: true, fromPushNotification: false, animated: true, completion: nil) + case let .show(show): + if let navigationController { let pageViewController = PageViewController(id: .show(show)) navigationController.pushViewController(pageViewController, animated: true) } - else { - let sectionViewController = SectionViewController(section: section.wrappedValue, filter: model.id) - navigationController.pushViewController(sectionViewController, animated: true) + case let .topic(topic): + if let navigationController { + let pageViewController = PageViewController(id: .topic(topic)) + navigationController.pushViewController(pageViewController, animated: true) + } + case let .highlight(_, highlightedItem): + if let navigationController { + if case let .show(show) = highlightedItem { + let pageViewController = PageViewController(id: .show(show)) + navigationController.pushViewController(pageViewController, animated: true) + } else if case let .media(media) = highlightedItem { + play_presentMediaPlayer(with: media, position: nil, airPlaySuggestions: true, fromPushNotification: false, animated: true, completion: nil) + } else { + let sectionViewController = SectionViewController(section: section.wrappedValue, filter: model.id) + navigationController.pushViewController(sectionViewController, animated: true) + } } + default: + () } + case .more: + if let navigationController { + let sectionViewController = SectionViewController(section: section.wrappedValue, filter: model.id) + navigationController.pushViewController(sectionViewController, animated: true) + } + } + } + + func collectionView(_: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point _: CGPoint) -> UIContextMenuConfiguration? { + let snapshot = dataSource.snapshot() + let section = snapshot.sectionIdentifiers[indexPath.section] + let item = snapshot.itemIdentifiers(inSection: section)[indexPath.row] + + switch item.wrappedValue { + case let .item(wrappedItem): + return ContextMenu.configuration(for: wrappedItem, at: indexPath, in: self) default: - () + return nil } - case .more: - if let navigationController { - let sectionViewController = SectionViewController(section: section.wrappedValue, filter: model.id) - navigationController.pushViewController(sectionViewController, animated: true) + } + + func collectionView(_: UICollectionView, willPerformPreviewActionForMenuWith _: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + ContextMenu.commitPreview(in: self, animator: animator) + } + + func collectionView(_ collectionView: UICollectionView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + preview(for: configuration, in: collectionView) + } + + func collectionView(_ collectionView: UICollectionView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + preview(for: configuration, in: collectionView) + } + + func collectionView(_: UICollectionView, willDisplaySupplementaryView _: UICollectionReusableView, forElementKind elementKind: String, at _: IndexPath) { + switch elementKind { + case Header.showHeader.rawValue, Header.titleHeader.rawValue: + headerWithTitleVisible = true + updateNavigationBar(animated: true) + default: + break } } - } - - func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { - let snapshot = dataSource.snapshot() - let section = snapshot.sectionIdentifiers[indexPath.section] - let item = snapshot.itemIdentifiers(inSection: section)[indexPath.row] - - switch item.wrappedValue { - case let .item(wrappedItem): - return ContextMenu.configuration(for: wrappedItem, at: indexPath, in: self) - default: - return nil + + func collectionView(_: UICollectionView, didEndDisplayingSupplementaryView _: UICollectionReusableView, forElementOfKind elementKind: String, at _: IndexPath) { + switch elementKind { + case Header.showHeader.rawValue, Header.titleHeader.rawValue: + headerWithTitleVisible = false + updateNavigationBar(animated: true) + default: + break + } } - } - - func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { - ContextMenu.commitPreview(in: self, animator: animator) - } - - func collectionView(_ collectionView: UICollectionView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { - return preview(for: configuration, in: collectionView) - } - - func collectionView(_ collectionView: UICollectionView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { - return preview(for: configuration, in: collectionView) - } - - func collectionView(_ collectionView: UICollectionView, willDisplaySupplementaryView view: UICollectionReusableView, forElementKind elementKind: String, at indexPath: IndexPath) { - switch elementKind { - case Header.showHeader.rawValue, Header.titleHeader.rawValue: - headerWithTitleVisible = true - updateNavigationBar(animated: true) - default: - break + + private func preview(for configuration: UIContextMenuConfiguration, in collectionView: UICollectionView) -> UITargetedPreview? { + guard let interactionView = ContextMenu.interactionView(in: collectionView, with: configuration) else { return nil } + let parameters = UIPreviewParameters() + parameters.backgroundColor = view.backgroundColor + return UITargetedPreview(view: interactionView, parameters: parameters) } - } - - func collectionView(_ collectionView: UICollectionView, didEndDisplayingSupplementaryView view: UICollectionReusableView, forElementOfKind elementKind: String, at indexPath: IndexPath) { - switch elementKind { - case Header.showHeader.rawValue, Header.titleHeader.rawValue: - headerWithTitleVisible = false - updateNavigationBar(animated: true) - default: - break + #endif + + #if os(tvOS) + func collectionView(_: UICollectionView, canFocusItemAt _: IndexPath) -> Bool { + false } - } - - private func preview(for configuration: UIContextMenuConfiguration, in collectionView: UICollectionView) -> UITargetedPreview? { - guard let interactionView = ContextMenu.interactionView(in: collectionView, with: configuration) else { return nil } - let parameters = UIPreviewParameters() - parameters.backgroundColor = view.backgroundColor - return UITargetedPreview(view: interactionView, parameters: parameters) - } -#endif - -#if os(tvOS) - func collectionView(_ collectionView: UICollectionView, canFocusItemAt indexPath: IndexPath) -> Bool { - return false - } -#endif + #endif } extension PageViewController: UIScrollViewDelegate { -#if os(iOS) - func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { - // Avoid the collection jumping when pulling to refresh. Only mark the refresh as being triggered. - if refreshTriggered { - model.reload(deep: true) - refreshTriggered = false + #if os(iOS) + func scrollViewDidEndDecelerating(_: UIScrollView) { + // Avoid the collection jumping when pulling to refresh. Only mark the refresh as being triggered. + if refreshTriggered { + model.reload(deep: true) + refreshTriggered = false + } } - } - - // The system default behavior does not lead to correct results when large titles are displayed. Override. - func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { - scrollView.play_scrollToTop(animated: true) - return false - } -#endif - + + // The system default behavior does not lead to correct results when large titles are displayed. Override. + func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { + scrollView.play_scrollToTop(animated: true) + return false + } + #endif + func scrollViewDidScroll(_ scrollView: UIScrollView) { guard scrollView.contentSize.height > 0 else { return } - + let numberOfScreens = 4 if scrollView.contentOffset.y > scrollView.contentSize.height - CGFloat(numberOfScreens) * scrollView.frame.height { model.loadMore() } - -#if os(iOS) - if isShowHeaderVerticalLayout { - topicGradientViewStickyTopAnchor.constant = showPageStickyTopAnchorConstant - } -#endif + + #if os(iOS) + if isShowHeaderVerticalLayout { + topicGradientViewStickyTopAnchor.constant = showPageStickyTopAnchorConstant + } + #endif } } #if os(iOS) -extension PageViewController: PlayApplicationNavigation { - func open(_ applicationSectionInfo: ApplicationSectionInfo) -> Bool { - guard radioChannel === applicationSectionInfo.radioChannel || radioChannel == applicationSectionInfo.radioChannel else { return false } - - switch applicationSectionInfo.applicationSection { - case .showByDate: - let date = applicationSectionInfo.options?[ApplicationSectionOptionKey.showByDateDateKey] as? Date - if let navigationController { - let showByDateViewController = Self.showByDateViewController(radioChannel: radioChannel, date: date) - navigationController.pushViewController(showByDateViewController, animated: false) - } - return true - case .showAZ: - if let navigationController { - let initialSectionId = applicationSectionInfo.options?[ApplicationSectionOptionKey.showAZIndexKey] as? String - let showsViewController = SectionViewController.showsViewController(forChannelUid: radioChannel?.uid, initialSectionId: initialSectionId) - navigationController.pushViewController(showsViewController, animated: false) - } - return true - default: - switch self.model.id { - case .live: - return applicationSectionInfo.applicationSection == .live + extension PageViewController: PlayApplicationNavigation { + func open(_ applicationSectionInfo: ApplicationSectionInfo) -> Bool { + guard radioChannel === applicationSectionInfo.radioChannel || radioChannel == applicationSectionInfo.radioChannel else { return false } + + switch applicationSectionInfo.applicationSection { + case .showByDate: + let date = applicationSectionInfo.options?[ApplicationSectionOptionKey.showByDateDateKey] as? Date + if let navigationController { + let showByDateViewController = Self.showByDateViewController(radioChannel: radioChannel, date: date) + navigationController.pushViewController(showByDateViewController, animated: false) + } + return true + case .showAZ: + if let navigationController { + let initialSectionId = applicationSectionInfo.options?[ApplicationSectionOptionKey.showAZIndexKey] as? String + let showsViewController = SectionViewController.showsViewController(forChannelUid: radioChannel?.uid, initialSectionId: initialSectionId) + navigationController.pushViewController(showsViewController, animated: false) + } + return true default: - return applicationSectionInfo.applicationSection == .overview + switch model.id { + case .live: + return applicationSectionInfo.applicationSection == .live + default: + return applicationSectionInfo.applicationSection == .overview + } } } } -} -extension PageViewController: ShowAccessCellActions { - func openShowAZ() { - if let navigationController { - let showsViewController = SectionViewController.showsViewController(forChannelUid: radioChannel?.uid) - navigationController.pushViewController(showsViewController, animated: true) - } - } - - func openShowByDate() { - if let navigationController { - let showByDateViewController = Self.showByDateViewController(radioChannel: radioChannel, date: nil) - navigationController.pushViewController(showByDateViewController, animated: true) + extension PageViewController: ShowAccessCellActions { + func openShowAZ() { + if let navigationController { + let showsViewController = SectionViewController.showsViewController(forChannelUid: radioChannel?.uid) + navigationController.pushViewController(showsViewController, animated: true) + } } - } -} -extension PageViewController: SectionHeaderViewAction { - fileprivate func openSection(sender: Any?, event: OpenSectionEvent?) { - if let event { - if let microPageId = event.section.wrappedValue.properties.openContentPageId { - openContentPage(id: microPageId) - } else { - openSectionPage(section: event.section, filter: model.id) + func openShowByDate() { + if let navigationController { + let showByDateViewController = Self.showByDateViewController(radioChannel: radioChannel, date: nil) + navigationController.pushViewController(showByDateViewController, animated: true) } } } - - private func openSectionPage(section: PageViewModel.Section, filter: PageViewModel.Id) { - guard let navigationController else { return } - - let sectionViewController = SectionViewController(section: section.wrappedValue, filter: filter) - navigationController.pushViewController(sectionViewController, animated: true) - } - - private func openContentPage(id: String) { - guard let navigationController else { return } - - SRGDataProvider.current!.contentPage(for: ApplicationConfiguration.shared.vendor, uid: id) - .receive(on: DispatchQueue.main) - .sink { result in - if case .failure = result { - let error = NSError( - domain: PlayErrorDomain, - code: PlayErrorCode.notFound.rawValue, - userInfo: [ - NSLocalizedDescriptionKey: NSLocalizedString("The page cannot be opened.", comment: "Error message when a page cannot be opened from a page section title") - ]) - Banner.showError(error) + + extension PageViewController: SectionHeaderViewAction { + fileprivate func openSection(sender _: Any?, event: OpenSectionEvent?) { + if let event { + if let microPageId = event.section.wrappedValue.properties.openContentPageId { + openContentPage(id: microPageId) + } else { + openSectionPage(section: event.section, filter: model.id) } - } receiveValue: { contentPage in - let pageViewController = PageViewController.pageViewController(for: contentPage) - navigationController.pushViewController(pageViewController, animated: true) } - .store(in: &cancellables) + } + + private func openSectionPage(section: PageViewModel.Section, filter: PageViewModel.Id) { + guard let navigationController else { return } + + let sectionViewController = SectionViewController(section: section.wrappedValue, filter: filter) + navigationController.pushViewController(sectionViewController, animated: true) + } + + private func openContentPage(id: String) { + guard let navigationController else { return } + + SRGDataProvider.current!.contentPage(for: ApplicationConfiguration.shared.vendor, uid: id) + .receive(on: DispatchQueue.main) + .sink { result in + if case .failure = result { + let error = NSError( + domain: PlayErrorDomain, + code: PlayErrorCode.notFound.rawValue, + userInfo: [ + NSLocalizedDescriptionKey: NSLocalizedString("The page cannot be opened.", comment: "Error message when a page cannot be opened from a page section title") + ] + ) + Banner.showError(error) + } + } receiveValue: { contentPage in + let pageViewController = PageViewController.pageViewController(for: contentPage) + navigationController.pushViewController(pageViewController, animated: true) + } + .store(in: &cancellables) + } } -} -extension PageViewController: TabBarActionable { - func performActiveTabAction(animated: Bool) { - collectionView?.play_scrollToTop(animated: animated) + extension PageViewController: TabBarActionable { + func performActiveTabAction(animated: Bool) { + collectionView?.play_scrollToTop(animated: animated) + } } -} #endif extension PageViewController: ShowHeaderViewAction { - func showMore(sender: Any?, event: ShowMoreEvent?) { + func showMore(sender _: Any?, event: ShowMoreEvent?) { guard let event else { return } - -#if os(iOS) - let sheetTextViewController = UIHostingController(rootView: SheetTextView(content: event.content)) - if #available(iOS 15.0, *) { - if let sheet = sheetTextViewController.sheetPresentationController { - sheet.detents = [.medium()] + + #if os(iOS) + let sheetTextViewController = UIHostingController(rootView: SheetTextView(content: event.content)) + if #available(iOS 15.0, *) { + if let sheet = sheetTextViewController.sheetPresentationController { + sheet.detents = [.medium()] + } } - } - present(sheetTextViewController, animated: true, completion: nil) -#else - navigateToText(event.content) -#endif + present(sheetTextViewController, animated: true, completion: nil) + #else + navigateToText(event.content) + #endif } } @@ -737,198 +734,194 @@ private extension PageViewController { private static let layoutVerticalMargin: CGFloat = constant(iOS: 8, tvOS: 0) private static let layoutHorizontalConfigurationViewMargin: CGFloat = constant(iOS: 0, tvOS: 8) private static let layoutTopicGradientViewHeight: CGFloat = 572 - + private static func layoutDisplayedTitleTopPadding(_ required: Bool) -> CGFloat { - return required ? layoutVerticalMargin * 2 : 0 + required ? layoutVerticalMargin * 2 : 0 } - + private static func layoutConfiguration(model: PageViewModel, layoutWidth: CGFloat, horizontalSizeClass: UIUserInterfaceSizeClass, offsetX: CGFloat) -> UICollectionViewCompositionalLayoutConfiguration { let configuration = UICollectionViewCompositionalLayoutConfiguration() configuration.interSectionSpacing = constant(iOS: 35, tvOS: 70) configuration.contentInsetsReference = constant(iOS: .automatic, tvOS: .layoutMargins) - + if let title = model.displayedTitle { let titleHeaderSize = TitleHeaderViewSize.recommended(for: title, description: model.displayedTitleDescription, topPadding: layoutDisplayedTitleTopPadding(model.displayedTitleNeedsTopPadding), layoutWidth: layoutWidth - layoutHorizontalConfigurationViewMargin * 2, horizontalSizeClass: horizontalSizeClass) - configuration.boundarySupplementaryItems = [ NSCollectionLayoutBoundarySupplementaryItem(layoutSize: titleHeaderSize, elementKind: Header.titleHeader.rawValue, alignment: .topLeading, absoluteOffset: CGPoint(x: offsetX, y: 0)) ] - } - else if let show = model.displayedShow { + configuration.boundarySupplementaryItems = [NSCollectionLayoutBoundarySupplementaryItem(layoutSize: titleHeaderSize, elementKind: Header.titleHeader.rawValue, alignment: .topLeading, absoluteOffset: CGPoint(x: offsetX, y: 0))] + } else if let show = model.displayedShow { let showHeaderSize = ShowHeaderViewSize.recommended(for: show, horizontalPadding: layoutHorizontalMargin, layoutWidth: layoutWidth - layoutHorizontalConfigurationViewMargin * 2, horizontalSizeClass: horizontalSizeClass) - configuration.boundarySupplementaryItems = [ NSCollectionLayoutBoundarySupplementaryItem(layoutSize: showHeaderSize, elementKind: Header.showHeader.rawValue, alignment: .topLeading, absoluteOffset: CGPoint(x: offsetX + layoutHorizontalConfigurationViewMargin, y: 0)) ] + configuration.boundarySupplementaryItems = [NSCollectionLayoutBoundarySupplementaryItem(layoutSize: showHeaderSize, elementKind: Header.showHeader.rawValue, alignment: .topLeading, absoluteOffset: CGPoint(x: offsetX + layoutHorizontalConfigurationViewMargin, y: 0))] } - + return configuration } - + private func layout(for model: PageViewModel) -> UICollectionViewLayout { - return UICollectionViewCompositionalLayout(sectionProvider: { [weak self] sectionIndex, layoutEnvironment in + UICollectionViewCompositionalLayout(sectionProvider: { [weak self] sectionIndex, layoutEnvironment in let layoutWidth = layoutEnvironment.container.effectiveContentSize.width let horizontalSizeClass = layoutEnvironment.traitCollection.horizontalSizeClass - - func sectionSupplementaryItems(for section: PageViewModel.Section, horizontalMargin: CGFloat) -> [NSCollectionLayoutBoundarySupplementaryItem] { + + func sectionSupplementaryItems(for section: PageViewModel.Section, horizontalMargin _: CGFloat) -> [NSCollectionLayoutBoundarySupplementaryItem] { let headerSize = SectionHeaderView.size(section: section, layoutWidth: layoutWidth) let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .topLeading) return [header] } - + func horizontalMargin(for section: PageViewModel.Section) -> CGFloat { switch section.viewModelProperties.layout { case .mediaList: -#if os(iOS) - return horizontalSizeClass == .compact ? Self.layoutHorizontalMargin : Self.layoutHorizontalMargin * 2 -#else - return Self.layoutHorizontalMargin -#endif + #if os(iOS) + return horizontalSizeClass == .compact ? Self.layoutHorizontalMargin : Self.layoutHorizontalMargin * 2 + #else + return Self.layoutHorizontalMargin + #endif default: return Self.layoutHorizontalMargin } } - + func layoutSection(for section: PageViewModel.Section) -> NSCollectionLayoutSection { switch section.viewModelProperties.layout { case .heroStage: let layoutSection = NSCollectionLayoutSection.horizontal(layoutWidth: layoutWidth, horizontalMargin: horizontalMargin(for: section), spacing: Self.itemSpacing) { layoutWidth, _ in - return HeroMediaCellSize.recommended(layoutWidth: layoutWidth, horizontalSizeClass: horizontalSizeClass) + HeroMediaCellSize.recommended(layoutWidth: layoutWidth, horizontalSizeClass: horizontalSizeClass) } layoutSection.orthogonalScrollingBehavior = .groupPaging return layoutSection case .highlight: return NSCollectionLayoutSection.horizontal(layoutWidth: layoutWidth, horizontalMargin: horizontalMargin(for: section), spacing: Self.itemSpacing) { layoutWidth, _ in - return HighlightCellSize.fullWidth(layoutWidth: layoutWidth, horizontalSizeClass: horizontalSizeClass) + HighlightCellSize.fullWidth(layoutWidth: layoutWidth, horizontalSizeClass: horizontalSizeClass) } case .headline: let layoutSection = NSCollectionLayoutSection.horizontal(layoutWidth: layoutWidth, horizontalMargin: horizontalMargin(for: section), spacing: Self.itemSpacing) { layoutWidth, _ in - return FeaturedContentCellSize.headline(layoutWidth: layoutWidth, horizontalSizeClass: horizontalSizeClass) + FeaturedContentCellSize.headline(layoutWidth: layoutWidth, horizontalSizeClass: horizontalSizeClass) } layoutSection.orthogonalScrollingBehavior = .groupPaging return layoutSection case .element: return NSCollectionLayoutSection.horizontal(layoutWidth: layoutWidth, horizontalMargin: horizontalMargin(for: section), spacing: Self.itemSpacing) { layoutWidth, _ in - return FeaturedContentCellSize.element(layoutWidth: layoutWidth, horizontalSizeClass: horizontalSizeClass) + FeaturedContentCellSize.element(layoutWidth: layoutWidth, horizontalSizeClass: horizontalSizeClass) } case .elementSwimlane: let layoutSection = NSCollectionLayoutSection.horizontal(layoutWidth: layoutWidth, horizontalMargin: horizontalMargin(for: section), spacing: Self.itemSpacing) { layoutWidth, _ in - return FeaturedContentCellSize.element(layoutWidth: layoutWidth, horizontalSizeClass: horizontalSizeClass) + FeaturedContentCellSize.element(layoutWidth: layoutWidth, horizontalSizeClass: horizontalSizeClass) } layoutSection.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary return layoutSection case .mediaSwimlane: let layoutSection = NSCollectionLayoutSection.horizontal(layoutWidth: layoutWidth, horizontalMargin: horizontalMargin(for: section), spacing: Self.itemSpacing) { _, _ in - return MediaCellSize.swimlane() + MediaCellSize.swimlane() } layoutSection.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary return layoutSection case .liveMediaSwimlane: let layoutSection = NSCollectionLayoutSection.horizontal(layoutWidth: layoutWidth, horizontalMargin: horizontalMargin(for: section), spacing: Self.itemSpacing) { _, _ in - return LiveMediaCellSize.swimlane() + LiveMediaCellSize.swimlane() } layoutSection.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary return layoutSection case .showSwimlane: let layoutSection = NSCollectionLayoutSection.horizontal(layoutWidth: layoutWidth, horizontalMargin: horizontalMargin(for: section), spacing: Self.itemSpacing) { _, _ in - return ShowCellSize.swimlane(for: section.properties.imageVariant) + ShowCellSize.swimlane(for: section.properties.imageVariant) } layoutSection.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary return layoutSection case .topicSelector: let layoutSection = NSCollectionLayoutSection.horizontal(layoutWidth: layoutWidth, horizontalMargin: horizontalMargin(for: section), spacing: Self.itemSpacing) { _, _ in - return TopicCellSize.swimlane() + TopicCellSize.swimlane() } layoutSection.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary return layoutSection case .mediaGrid: if horizontalSizeClass == .compact { return NSCollectionLayoutSection.horizontal(layoutWidth: layoutWidth, horizontalMargin: horizontalMargin(for: section), spacing: Self.itemSpacing) { _, _ in - return MediaCellSize.fullWidth() + MediaCellSize.fullWidth() } - } - else { + } else { return NSCollectionLayoutSection.grid(layoutWidth: layoutWidth, horizontalMargin: horizontalMargin(for: section), spacing: Self.itemSpacing) { layoutWidth, spacing in - return MediaCellSize.grid(layoutWidth: layoutWidth, spacing: spacing) + MediaCellSize.grid(layoutWidth: layoutWidth, spacing: spacing) } } case .liveMediaGrid: return NSCollectionLayoutSection.grid(layoutWidth: layoutWidth, horizontalMargin: horizontalMargin(for: section), spacing: Self.itemSpacing) { layoutWidth, spacing in - return LiveMediaCellSize.grid(layoutWidth: layoutWidth, spacing: spacing) + LiveMediaCellSize.grid(layoutWidth: layoutWidth, spacing: spacing) } case .showGrid: return NSCollectionLayoutSection.grid(layoutWidth: layoutWidth, horizontalMargin: horizontalMargin(for: section), spacing: Self.itemSpacing) { layoutWidth, spacing in - return ShowCellSize.grid(for: section.properties.imageVariant, layoutWidth: layoutWidth, spacing: spacing) + ShowCellSize.grid(for: section.properties.imageVariant, layoutWidth: layoutWidth, spacing: spacing) } -#if os(iOS) - case .showAccess: - return NSCollectionLayoutSection.horizontal(layoutWidth: layoutWidth, horizontalMargin: horizontalMargin(for: section), spacing: Self.itemSpacing) { _, _ in - return ShowAccessCellSize.fullWidth() - } -#endif + #if os(iOS) + case .showAccess: + return NSCollectionLayoutSection.horizontal(layoutWidth: layoutWidth, horizontalMargin: horizontalMargin(for: section), spacing: Self.itemSpacing) { _, _ in + ShowAccessCellSize.fullWidth() + } + #endif case .mediaList: -#if os(iOS) - return NSCollectionLayoutSection.horizontal(layoutWidth: layoutWidth, horizontalMargin: horizontalMargin(for: section), spacing: Self.itemSpacing) { _, _ in - return MediaCellSize.fullWidth(horizontalSizeClass: horizontalSizeClass) - } -#else - return NSCollectionLayoutSection.grid(layoutWidth: layoutWidth, horizontalMargin: horizontalMargin(for: section), spacing: Self.itemSpacing) { layoutWidth, spacing in - return MediaCellSize.grid(layoutWidth: layoutWidth, spacing: spacing) - } -#endif + #if os(iOS) + return NSCollectionLayoutSection.horizontal(layoutWidth: layoutWidth, horizontalMargin: horizontalMargin(for: section), spacing: Self.itemSpacing) { _, _ in + MediaCellSize.fullWidth(horizontalSizeClass: horizontalSizeClass) + } + #else + return NSCollectionLayoutSection.grid(layoutWidth: layoutWidth, horizontalMargin: horizontalMargin(for: section), spacing: Self.itemSpacing) { layoutWidth, spacing in + MediaCellSize.grid(layoutWidth: layoutWidth, spacing: spacing) + } + #endif } } - + guard let self else { return nil } - - let snapshot = self.dataSource.snapshot() + + let snapshot = dataSource.snapshot() let section = snapshot.sectionIdentifiers[sectionIndex] - + let layoutSection = layoutSection(for: section) layoutSection.boundarySupplementaryItems = sectionSupplementaryItems(for: section, horizontalMargin: horizontalMargin(for: section)) return layoutSection }, configuration: Self.layoutConfiguration(model: model, layoutWidth: 0, horizontalSizeClass: .unspecified, offsetX: 0)) } - + private func updateTopicGradientLayout() { let topScreenOffset = constant(iOS: collectionView.safeAreaInsets.top, tvOS: 0) - + if case .show = model.id { let configuration = Self.layoutConfiguration(model: model, layoutWidth: view.safeAreaLayoutGuide.layoutFrame.width, horizontalSizeClass: view.traitCollection.horizontalSizeClass, offsetX: view.safeAreaLayoutGuide.layoutFrame.minX) - let supplementaryItemsHeight = configuration.boundarySupplementaryItems.map { $0.layoutSize.heightDimension.dimension }.reduce(0, +) + let supplementaryItemsHeight = configuration.boundarySupplementaryItems.map(\.layoutSize.heightDimension.dimension).reduce(0, +) let mediaCellHeight = MediaCellSize.height(horizontalSizeClass: traitCollection.horizontalSizeClass) - + // Move the gradient view below the show image when displayed in compact horizontal size class if isShowHeaderVerticalLayout { topicGradientViewFixTopAnchor.priority = .defaultLow topicGradientViewStickyTopAnchor.priority = .defaultHigh - + topicGradientViewStickyTopAnchor.constant = showPageStickyTopAnchorConstant let showImageOffset = view.safeAreaLayoutGuide.layoutFrame.width / ShowHeaderView.imageAspectRatio topicGradientViewHeightAnchor.constant = supplementaryItemsHeight - showImageOffset + mediaCellHeight - } - else { + } else { topicGradientViewStickyTopAnchor.priority = .defaultLow topicGradientViewFixTopAnchor.priority = .defaultHigh - + topicGradientViewFixTopAnchor.constant = topScreenOffset topicGradientViewHeightAnchor.constant = supplementaryItemsHeight + mediaCellHeight } - } - else { + } else { topicGradientViewStickyTopAnchor.priority = .defaultLow topicGradientViewFixTopAnchor.priority = .defaultHigh - + topicGradientViewFixTopAnchor.constant = topScreenOffset topicGradientViewHeightAnchor.constant = Self.layoutTopicGradientViewHeight } } - + private var showPageStickyTopAnchorConstant: CGFloat { let showImageOffset = view.safeAreaLayoutGuide.layoutFrame.width / ShowHeaderView.imageAspectRatio let topScreenOffset = constant(iOS: collectionView.safeAreaInsets.top, tvOS: 0) let offset = topScreenOffset + collectionView.contentOffset.y return (offset < showImageOffset) ? showImageOffset : offset } - + private var isShowHeaderVerticalLayout: Bool { guard case .show = model.id else { return false } - + return ShowHeaderView.isVerticalLayout( horizontalSizeClass: traitCollection.horizontalSizeClass, isLandscape: UIApplication.shared.mainWindow?.isLandscape ?? false @@ -944,7 +937,7 @@ private extension PageViewController { let section: PageViewModel.Section let primaryColor: Color let secondaryColor: Color - + var body: some View { switch section.viewModelProperties.layout { case .heroStage: @@ -963,20 +956,20 @@ private extension PageViewController { PlaySRG.MediaCell(media: media, style: haveSameShow(media: media, in: section) ? .date : .show, layout: .vertical).primaryColor(primaryColor).secondaryColor(secondaryColor) } } - + private func haveSameShow(media: SRGMedia?, in section: PageViewModel.Section) -> Bool { guard let displayedShow = section.properties.displayedShow, let mediaShow = media?.show else { return false } - + return displayedShow.isEqual(mediaShow) } } - + struct ShowCell: View { let show: SRGShow? let section: PageViewModel.Section let primaryColor: Color let secondaryColor: Color - + var body: some View { switch section.viewModelProperties.layout { case .heroStage, .headline: @@ -988,13 +981,13 @@ private extension PageViewController { } } } - + struct ItemCell: View { let item: PageViewModel.Item let id: PageViewModel.Id let primaryColor: Color let secondaryColor: Color - + var body: some View { switch item.wrappedValue { case let .item(wrappedItem): @@ -1011,20 +1004,20 @@ private extension PageViewController { TopicCell(topic: nil) case let .topic(topic): TopicCell(topic: topic) -#if os(iOS) - case let .download(download): - DownloadCell(download: download) - case let .notification(notification): - NotificationCell(notification: notification) - case .showAccess: - switch id { - case .video: - let style: ShowAccessCell.Style = !ApplicationConfiguration.shared.isTvGuideUnavailable ? .programGuide : .calendar - ShowAccessCell(style: style).primaryColor(primaryColor) - default: - ShowAccessCell(style: .calendar).primaryColor(primaryColor) - } -#endif + #if os(iOS) + case let .download(download): + DownloadCell(download: download) + case let .notification(notification): + NotificationCell(notification: notification) + case .showAccess: + switch id { + case .video: + let style: ShowAccessCell.Style = !ApplicationConfiguration.shared.isTvGuideUnavailable ? .programGuide : .calendar + ShowAccessCell(style: style).primaryColor(primaryColor) + default: + ShowAccessCell(style: .calendar).primaryColor(primaryColor) + } + #endif case .highlightPlaceholder: HighlightCell(highlight: nil, section: item.section.wrappedValue, item: nil, filter: id) case let .highlight(highlight, highlightedItem): @@ -1047,12 +1040,12 @@ private extension PageViewController { private class OpenSectionEvent: UIEvent { let section: PageViewModel.Section - + init(section: PageViewModel.Section) { self.section = section super.init() } - + override init() { fatalError("init() is not available") } @@ -1062,54 +1055,53 @@ private extension PageViewController { private struct SectionHeaderView: View, PrimaryColorSettable { let section: PageViewModel.Section let pageId: PageViewModel.Id - - internal var primaryColor: Color = .srgGrayD2 - + + var primaryColor: Color = .srgGrayD2 + @FirstResponder private var firstResponder @AppStorage(PlaySRGSettingSectionWideSupportEnabled) var isSectionWideSupportEnabled = false - + private static func title(for section: PageViewModel.Section) -> String? { - return section.properties.title + section.properties.title } - + private static func subtitle(for section: PageViewModel.Section) -> String? { - return section.properties.summary + section.properties.summary } - + private var hasDetailDisclosure: Bool { - return section.viewModelProperties.canOpenPage || isSectionWideSupportEnabled + section.viewModelProperties.canOpenPage || isSectionWideSupportEnabled } - + var accessibilityLabel: String? { - return Self.title(for: section) + Self.title(for: section) } - + var accessibilityHint: String? { - return hasDetailDisclosure ? PlaySRGAccessibilityLocalizedString("Shows all contents.", comment: "Homepage header action hint") : nil + hasDetailDisclosure ? PlaySRGAccessibilityLocalizedString("Shows all contents.", comment: "Homepage header action hint") : nil } - + var body: some View { if section.properties.displaysRowHeader, let title = Self.title(for: section) { -#if os(tvOS) - HeaderView(title: title, subtitle: Self.subtitle(for: section), hasDetailDisclosure: false, primaryColor: primaryColor) -#else - Button { - firstResponder.sendAction(#selector(SectionHeaderViewAction.openSection(sender:event:)), for: OpenSectionEvent(section: section)) - } label: { - HeaderView(title: title, subtitle: Self.subtitle(for: section), hasDetailDisclosure: hasDetailDisclosure, primaryColor: primaryColor) - } - .disabled(!hasDetailDisclosure) - .responderChain(from: firstResponder) -#endif + #if os(tvOS) + HeaderView(title: title, subtitle: Self.subtitle(for: section), hasDetailDisclosure: false, primaryColor: primaryColor) + #else + Button { + firstResponder.sendAction(#selector(SectionHeaderViewAction.openSection(sender:event:)), for: OpenSectionEvent(section: section)) + } label: { + HeaderView(title: title, subtitle: Self.subtitle(for: section), hasDetailDisclosure: hasDetailDisclosure, primaryColor: primaryColor) + } + .disabled(!hasDetailDisclosure) + .responderChain(from: firstResponder) + #endif } } - + static func size(section: PageViewModel.Section, layoutWidth: CGFloat) -> NSCollectionLayoutSize { if section.properties.displaysRowHeader { - return HeaderViewSize.recommended(forTitle: title(for: section), subtitle: subtitle(for: section), layoutWidth: layoutWidth) - } - else { - return NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(LayoutHeaderHeightZero)) + HeaderViewSize.recommended(forTitle: title(for: section), subtitle: subtitle(for: section), layoutWidth: layoutWidth) + } else { + NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(LayoutHeaderHeightZero)) } } } diff --git a/Application/Sources/Content/PageViewModel.swift b/Application/Sources/Content/PageViewModel.swift index 06cce8632..d46a6874d 100644 --- a/Application/Sources/Content/PageViewModel.swift +++ b/Application/Sources/Content/PageViewModel.swift @@ -11,29 +11,28 @@ import SwiftUI final class PageViewModel: Identifiable, ObservableObject { let id: Id - + @Published private(set) var state: State = .loading @Published private(set) var serviceMessage: ServiceMessage? - + @Published private(set) var displayedShow: SRGShow? - + private let trigger = Trigger() - + init(id: Id) { self.id = id - + Publishers.Publish(onOutputFrom: reloadSignal()) { [weak self] in - return Self.pagePublisher(id: id) + Self.pagePublisher(id: id) .map { page in - return Publishers.AccumulateLatestMany(page.sections.map { section in - return Publishers.PublishAndRepeat(onOutputFrom: Self.rowReloadSignal(for: section, trigger: self?.trigger)) { - return Self.rowPublisher(id: id, - section: section, - pageSize: Self.pageSize(for: section, in: page.sections), - paginatedBy: self?.trigger.signal(activatedBy: TriggerId.loadMore(section: section)) - ) - .replaceError(with: Self.fallbackRow(for: section, state: self?.state)) - .prepend(Self.placeholderRow(for: section, state: self?.state)) + Publishers.AccumulateLatestMany(page.sections.map { section in + Publishers.PublishAndRepeat(onOutputFrom: Self.rowReloadSignal(for: section, trigger: self?.trigger)) { + Self.rowPublisher(id: id, + section: section, + pageSize: Self.pageSize(for: section, in: page.sections), + paginatedBy: self?.trigger.signal(activatedBy: TriggerId.loadMore(section: section))) + .replaceError(with: Self.fallbackRow(for: section, state: self?.state)) + .prepend(Self.placeholderRow(for: section, state: self?.state)) } }) .map { (page, $0) } @@ -44,12 +43,12 @@ final class PageViewModel: Identifiable, ObservableObject { State.loaded(rows: rows.filter { !$0.isEmpty }, pageUid: page.uid) } .catch { error in - return Just(State.failed(error: error, pageUid: self?.state.pageUid)) + Just(State.failed(error: error, pageUid: self?.state.pageUid)) } } .receive(on: DispatchQueue.main) .assign(to: &$state) - + Publishers.PublishAndRepeat(onOutputFrom: reloadSignal()) { URLSession.shared.dataTaskPublisher(for: ApplicationConfiguration.shared.serviceMessageUrl) .map(\.data) @@ -61,10 +60,10 @@ final class PageViewModel: Identifiable, ObservableObject { .removeDuplicates() .receive(on: DispatchQueue.main) .assign(to: &$serviceMessage) - + if case let .show(show) = id { - self.displayedShow = show - + displayedShow = show + // The show page needs `topics` which could be available only in the show request. SRGDataProvider.current!.show(withUrn: show.urn) .map { $0 } @@ -73,26 +72,25 @@ final class PageViewModel: Identifiable, ObservableObject { .assign(to: &$displayedShow) } } - + func loadMore() { if let lastSection = state.sections.last, Self.hasLoadMore(for: lastSection, in: state.sections) { trigger.activate(for: TriggerId.loadMore(section: lastSection)) } } - + func reload(deep: Bool = false) { if deep || state.sections.isEmpty { trigger.activate(for: TriggerId.reload) - } - else { + } else { for section in state.sections where !Self.hasLoadMore(for: section, in: state.sections) { trigger.activate(for: TriggerId.reloadSection(section)) } } } - + private func reloadSignal() -> AnyPublisher { - return Publishers.Merge4( + Publishers.Merge4( trigger.signal(activatedBy: TriggerId.reload), ApplicationSignal.wokenUp() .filter { [weak self] in @@ -109,49 +107,46 @@ final class PageViewModel: Identifiable, ObservableObject { .throttle(for: 0.5, scheduler: DispatchQueue.main, latest: false) .eraseToAnyPublisher() } - + private static func rowReloadSignal(for section: Section, trigger: Trigger?) -> AnyPublisher { - return Publishers.Merge( + Publishers.Merge( section.properties.reloadSignal() ?? PassthroughSubject().eraseToAnyPublisher(), trigger?.signal(activatedBy: TriggerId.reloadSection(section)) ?? PassthroughSubject().eraseToAnyPublisher() ) .eraseToAnyPublisher() } - + private static func hasLoadMore(for section: Section, in sections: [Section]) -> Bool { - if section == sections.last && section.viewModelProperties.hasLoadMore { - return true - } - else { - return false + if section == sections.last, section.viewModelProperties.hasLoadMore { + true + } else { + false } } - + private static func pageSize(for section: Section, in sections: [Section]) -> UInt { let configuration = ApplicationConfiguration.shared return hasLoadMore(for: section, in: sections) ? configuration.detailPageSize : configuration.pageSize } - + private static func placeholderRow(for section: Section, state: State?) -> Row { if let row = state?.rows.first(where: { $0.section == section }) { - return row - } - else { - return Row(section: section, items: Self.placeholderRowItems(for: section)) + row + } else { + Row(section: section, items: Self.placeholderRowItems(for: section)) } } - + private static func fallbackRow(for section: Section, state: State?) -> Row { if let row = state?.rows.first(where: { $0.section == section }) { - return row - } - else { - return Row(section: section, items: []) + row + } else { + Row(section: section, items: []) } } - + private static func placeholderRowItems(for section: Section) -> [Item] { - return section.properties.placeholderRowItems.map { Item(.item($0), in: section) } + section.properties.placeholderRowItems.map { Item(.item($0), in: section) } } } @@ -160,90 +155,90 @@ final class PageViewModel: Identifiable, ObservableObject { extension PageViewModel { enum Id: SectionFiltering { case video - case audio(channel: RadioChannel) + case audio(channel: RadioChannel?) case live case topic(_ topic: SRGTopic) case show(_ show: SRGShow) case page(_ page: SRGContentPage) - -#if os(iOS) - var sharingItem: SharingItem? { - switch self { - case let .show(show): - return SharingItem(for: show) - case let .page(page): - return SharingItem(for: page) - default: - return nil + + #if os(iOS) + var sharingItem: SharingItem? { + switch self { + case let .show(show): + SharingItem(for: show) + case let .page(page): + SharingItem(for: page) + default: + nil + } } - } -#endif - + #endif + var supportsCastButton: Bool { switch self { case .video, .audio, .live: - return true + true default: - return false + false } } - + var isConfigured: Bool { switch self { case .audio, .live: - return true + true default: - return false + false } } - + var title: String? { switch self { case .video: - return NSLocalizedString("Videos", comment: "Title displayed at the top of the video view") + NSLocalizedString("Videos", comment: "Title displayed at the top of the video view") case .audio: - return NSLocalizedString("Audios", comment: "Title displayed at the top of the audio view") + NSLocalizedString("Audios", comment: "Title displayed at the top of the audio view") case .live: - return NSLocalizedString("Livestreams", comment: "Title displayed at the top of the livestreams view") + NSLocalizedString("Livestreams", comment: "Title displayed at the top of the livestreams view") case let .topic(topic): - return topic.title + topic.title case let .show(show): - return show.title + show.title case let .page(page): - return page.title + page.title } } - + var analyticsPageViewTitle: String { switch self { case .video, .audio, .live: - return AnalyticsPageTitle.home.rawValue + AnalyticsPageTitle.home.rawValue case let .topic(topic): - return topic.title + topic.title case let .show(show): - return show.title + show.title case let .page(page): - return page.title + page.title } } - + var analyticsPageViewType: String { switch self { case .video, .audio, .page: - return AnalyticsPageType.landingPage.rawValue - case .live: - return AnalyticsPageType.live.rawValue + AnalyticsPageType.landingPage.rawValue + case .live: + AnalyticsPageType.live.rawValue case .topic, .show: - return AnalyticsPageType.overview.rawValue + AnalyticsPageType.overview.rawValue } } - + var analyticsPageViewLevels: [String]? { switch self { case .video: return [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.video.rawValue] case let .audio(channel: channel): - return [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.audio.rawValue, channel.name] + return [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.audio.rawValue, channel?.name].compactMap { $0 } case .live: return [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.live.rawValue] case .topic: @@ -256,76 +251,83 @@ extension PageViewModel { return [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.video.rawValue, level3] } } - + func analyticsPageViewLabels(pageUid: String?) -> SRGAnalyticsPageViewLabels? { guard let pageUid else { return nil } - + let pageViewLabels = SRGAnalyticsPageViewLabels() pageViewLabels.customInfo = ["pac_page_id": pageUid] return pageViewLabels } - + func canContain(show: SRGShow) -> Bool { switch self { case .video: - return show.transmission == .TV + show.transmission == .TV case let .audio(channel: channel): - return show.transmission == .radio && show.primaryChannelUid == channel.uid + if let channel { + show.transmission == .radio && show.primaryChannelUid == channel.uid + } else { + show.transmission == .radio + } default: - return false + false } } - + func compatibleShows(_ shows: [SRGShow]) -> [SRGShow] { - return shows.filter { canContain(show: $0) } + shows.filter { canContain(show: $0) } } - + func compatibleMedias(_ medias: [SRGMedia]) -> [SRGMedia] { switch self { case .video: - return medias.filter { $0.mediaType == .video } + medias.filter { $0.mediaType == .video } case let .audio(channel: channel): - return medias.filter { $0.mediaType == .audio && ($0.channel?.uid == channel.uid || $0.show?.primaryChannelUid == channel.uid) } + if let channel { + medias.filter { $0.mediaType == .audio && ($0.channel?.uid == channel.uid || $0.show?.primaryChannelUid == channel.uid) } + } else { + medias.filter { $0.mediaType == .audio } + } default: - return medias + medias } } } - + enum State { case loading case failed(error: Error, pageUid: String?) case loaded(rows: [Row], pageUid: String?) - + var rows: [Row] { if case let .loaded(rows: rows, _) = self { - return rows - } - else { - return [] + rows + } else { + [] } } - + var sections: [Section] { - return rows.map(\.section) + rows.map(\.section) } - + var isEmpty: Bool { - return rows.isEmpty + rows.isEmpty } - + var pageUid: String? { switch self { case .loading: - return nil + nil case let .failed(_, pageUid: pageUid): - return pageUid + pageUid case let .loaded(_, pageUid: pageUid): - return pageUid + pageUid } } } - + enum SectionLayout: Hashable { case heroStage case highlight @@ -340,56 +342,56 @@ extension PageViewModel { case showGrid case showSwimlane case topicSelector -#if os(iOS) - case showAccess -#endif + #if os(iOS) + case showAccess + #endif } - + fileprivate struct Page: Hashable { let uid: String? let sections: [Section] } - + struct Section: Hashable { let wrappedValue: Content.Section - let index: Int // TODO: Remove when all pages are configured with PAC - + let index: Int // TODO: Remove when all pages are configured with PAC + init(_ wrappedValue: Content.Section, index: Int) { self.wrappedValue = wrappedValue self.index = index } - + var properties: SectionProperties { - return wrappedValue.properties + wrappedValue.properties } - + var viewModelProperties: PageViewModelProperties { switch wrappedValue { - case let .content(section, _): - return ContentSectionProperties(contentSection: section) + case let .content(section, _, _): + ContentSectionProperties(contentSection: section) case let .configured(section): - return ConfiguredSectionProperties(configuredSection: section, index: index) + ConfiguredSectionProperties(configuredSection: section, index: index) } } } - + struct Item: Hashable { enum WrappedValue: Hashable { case item(Content.Item) case more } - + let wrappedValue: WrappedValue let section: Section - + init(_ wrappedValue: WrappedValue, in section: Section) { self.wrappedValue = wrappedValue self.section = section } } - + typealias Row = CollectionRow - + enum TriggerId: Hashable { case reload case reloadSection(Section) @@ -400,33 +402,34 @@ extension PageViewModel { // MARK: Header and navigation extension PageViewModel { -#if os(iOS) - var isHeaderWithTitle: Bool { - return displayedTitle != nil || displayedShow != nil - } - - var isLargeTitleDisplayMode: Bool { - if isHeaderWithTitle { - return false + #if os(iOS) + var isHeaderWithTitle: Bool { + displayedTitle != nil || displayedShow != nil } - else { - // Avoid iOS automatic scroll insets / offset bugs occurring if large titles are desired by a view controller - // but the navigation bar is hidden. The scroll insets are incorrect and sometimes the scroll offset might - // be incorrect at the top. - return !isNavigationBarHidden + + var isLargeTitleDisplayMode: Bool { + if isHeaderWithTitle { + false + } else { + // Avoid iOS automatic scroll insets / offset bugs occurring if large titles are desired by a view controller + // but the navigation bar is hidden. The scroll insets are incorrect and sometimes the scroll offset might + // be incorrect at the top. + !isNavigationBarHidden + } } - } - - var isNavigationBarHidden: Bool { - switch id { - case .video: - return true - default: - return false + + var isNavigationBarHidden: Bool { + switch id { + case .video: + true + case let .audio(channel: channel): + channel == nil + default: + false + } } - } -#endif - + #endif + var primaryColor: Color { switch id { case let .topic(topic): @@ -438,49 +441,46 @@ extension PageViewModel { return .srgGrayD2 } } - + var secondaryColor: Color { - return .srgGray96 + .srgGray96 } - + var displayedTitle: String? { switch id { case let .page(page): - return page.title + page.title case let .topic(topic): - return topic.title + topic.title default: - return nil + nil } } - + var displayedTitleDescription: String? { if case let .page(page) = id { - return page.summary - } - else { - return nil + page.summary + } else { + nil } } - + var displayedTitleTextAlignment: TextAlignment { if case .topic = id { - return constant(iOS: .leading, tvOS: .center) - } - else { - return .leading + constant(iOS: .leading, tvOS: .center) + } else { + .leading } } - + var displayedTitleNeedsTopPadding: Bool { if case let .topic(topic) = id, ApplicationConfiguration.shared.topicColors(for: topic) != nil { - return constant(iOS: true, tvOS: false) - } - else { - return false + constant(iOS: true, tvOS: false) + } else { + false } } - + var displayedGradientTopic: SRGTopic? { switch id { case let .topic(topic): @@ -492,15 +492,15 @@ extension PageViewModel { return nil } } - + var displayedGradientTopicStyle: TopicGradientView.Style? { switch id { case .topic: - return .topicPage + .topicPage case .show: - return .showPage + .showPage default: - return nil + nil } } } @@ -511,10 +511,11 @@ extension PageViewModel { var userActivity: NSUserActivity? { { guard let bundleIdentifier = Bundle.main.bundleIdentifier, - let applicationVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") else { + let applicationVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") + else { return nil } - + if case let .show(show) = id { guard let data = try? NSKeyedArchiver.archivedData(withRootObject: show, requiringSecureCoding: false) else { return nil } let userActivity = NSUserActivity(activityType: bundleIdentifier.appending(".displaying")) @@ -525,15 +526,14 @@ extension PageViewModel { "SRGShowData": data, "applicationVersion": applicationVersion ]) -#if os(iOS) - userActivity.isEligibleForPrediction = true - userActivity.persistentIdentifier = show.urn - let suggestedInvocationPhraseFormat = show.transmission == .radio ? NSLocalizedString("Listen to %@", comment: "Suggested invocation phrase to listen to a show") : NSLocalizedString("Watch %@", comment: "Suggested invocation phrase to watch a show") - userActivity.suggestedInvocationPhrase = String(format: suggestedInvocationPhraseFormat, show.title) -#endif + #if os(iOS) + userActivity.isEligibleForPrediction = true + userActivity.persistentIdentifier = show.urn + let suggestedInvocationPhraseFormat = show.transmission == .radio ? NSLocalizedString("Listen to %@", comment: "Suggested invocation phrase to listen to a show") : NSLocalizedString("Watch %@", comment: "Suggested invocation phrase to watch a show") + userActivity.suggestedInvocationPhrase = String(format: suggestedInvocationPhraseFormat, show.title) + #endif return userActivity - } - else { + } else { return nil } }() @@ -546,53 +546,65 @@ private extension PageViewModel { static func pagePublisher(id: Id) -> AnyPublisher { switch id { case .video: - return SRGDataProvider.current!.contentPage(for: ApplicationConfiguration.shared.vendor, product: .playVideo) - .map { Page(uid: $0.uid, sections: $0.sections.enumeratedMap { Section(.content($0), index: $1) }) } + SRGDataProvider.current!.contentPage(for: ApplicationConfiguration.shared.vendor, product: .playVideo) + .map { Page(uid: $0.uid, sections: $0.sections.enumeratedMap { Section(.content($0, type: .videoOrTV), index: $1) }) } .eraseToAnyPublisher() case let .topic(topic): - return SRGDataProvider.current!.contentPage(for: topic.vendor, topicWithUrn: topic.urn) - .map { Page(uid: $0.uid, sections: $0.sections.enumeratedMap { Section(.content($0), index: $1) }) } + SRGDataProvider.current!.contentPage(for: topic.vendor, topicWithUrn: topic.urn) + // FIXME: is topic page always videoOrTV content type? + .map { Page(uid: $0.uid, sections: $0.sections.enumeratedMap { Section(.content($0, type: .videoOrTV), index: $1) }) } .eraseToAnyPublisher() case let .show(show): - if show.transmission == .TV && !ApplicationConfiguration.shared.isPredefinedShowPagePreferred { - return SRGDataProvider.current!.contentPage(for: show.vendor, product: show.transmission == .radio ? .playAudio : .playVideo, showWithUrn: show.urn) - .map { Page(uid: $0.uid, sections: $0.sections.enumeratedMap { Section(.content($0, show: show), index: $1) }) } + if show.transmission == .TV, !ApplicationConfiguration.shared.isPredefinedShowPagePreferred { + SRGDataProvider.current!.contentPage(for: show.vendor, product: show.transmission == .radio ? .playAudio : .playVideo, showWithUrn: show.urn) + .map { Page(uid: $0.uid, sections: $0.sections.enumeratedMap { Section(.content($0, type: show.play_contentType, show: show), index: $1) }) } .eraseToAnyPublisher() - } - else { - return Just(Page(uid: nil, sections: [ Section(.configured(.availableEpisodes(show)), index: 0) ] )) + } else { + Just(Page(uid: nil, sections: [Section(.configured(.availableEpisodes(show)), index: 0)])) .setFailureType(to: Error.self) .eraseToAnyPublisher() } case let .page(page): - return SRGDataProvider.current!.contentPage(for: page.vendor, uid: page.uid) - .map { Page(uid: $0.uid, sections: $0.sections.enumeratedMap { Section(.content($0), index: $1) }) } + SRGDataProvider.current!.contentPage(for: page.vendor, uid: page.uid) + // FIXME: is page always videoOrTV content type? + .map { Page(uid: $0.uid, sections: $0.sections.enumeratedMap { Section(.content($0, type: .videoOrTV), index: $1) }) } .eraseToAnyPublisher() case let .audio(channel: channel): - return Just(Page(uid: nil, sections: channel.configuredSections().enumeratedMap { Section(.configured($0), index: $1) })) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + if let channel, let uid = channel.contentPageId, ApplicationSettingAudioHomepageOption() == .curatedMany { + SRGDataProvider.current!.contentPage(for: ApplicationConfiguration.shared.vendor, uid: uid) + .map { Page(uid: $0.uid, sections: $0.sections.enumeratedMap { Section(.content($0, type: .audioOrRadio), index: $1) }) } + .eraseToAnyPublisher() + } else if let channel { + Just(Page(uid: nil, sections: channel.configuredSections().enumeratedMap { Section(.configured($0), index: $1) })) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } else { + SRGDataProvider.current!.contentPage(for: ApplicationConfiguration.shared.vendor, product: .playAudio) + .map { Page(uid: $0.uid, sections: $0.sections.enumeratedMap { Section(.content($0, type: .audioOrRadio), index: $1) }) } + .eraseToAnyPublisher() + } case .live: - return Just(Page(uid: nil, sections: ApplicationConfiguration.shared.liveConfiguredSections.enumeratedMap { Section(.configured($0), index: $1) })) + Just(Page(uid: nil, sections: ApplicationConfiguration.shared.liveConfiguredSections.enumeratedMap { Section(.configured($0), index: $1) })) .setFailureType(to: Error.self) .eraseToAnyPublisher() } } - + static func rowPublisher(id: Id, section: Section, pageSize: UInt, paginatedBy paginator: Trigger.Signal?) -> AnyPublisher { if let highlight = section.properties.rowHighlight { - return section.properties.publisher(pageSize: pageSize, paginatedBy: paginator, filter: id) + section.properties.publisher(pageSize: pageSize, paginatedBy: paginator, filter: id) .map { items in guard let firstItem = items.first else { return Row(section: section, items: []) } - - let highlightedItem = section.properties.hasHighlightedItem ? firstItem : nil + + let highlightedItem = section.properties.hasHighlightedItem ? firstItem : + section.properties.couldHaveHighlightedItem && items.count == 1 ? firstItem : nil + let item = Item(.item(.highlight(highlight, item: highlightedItem)), in: section) return Row(section: section, items: [item]) } .eraseToAnyPublisher() - } - else { - return Publishers.CombineLatest( + } else { + Publishers.CombineLatest( section.properties.publisher(pageSize: pageSize, paginatedBy: paginator, filter: id) .scan([]) { $0 + $1 }, section.properties.interactiveUpdatesPublisher() @@ -600,23 +612,23 @@ private extension PageViewModel { .setFailureType(to: Error.self) ) .map { items, removedItems in - return items.filter { !removedItems.contains($0) } + items.filter { !removedItems.contains($0) } } .map { rowItems(removeDuplicates(in: $0), in: section) } .map { Row(section: section, items: $0) } .eraseToAnyPublisher() } } - + static func rowItems(_ items: [Content.Item], in section: Section) -> [Item] { var rowItems = items.map { Item(.item($0), in: section) } -#if os(tvOS) - if !rowItems.isEmpty - && (section.viewModelProperties.canOpenPage || ApplicationSettingSectionWideSupportEnabled()) - && section.viewModelProperties.hasMoreRowItem { - rowItems.append(Item(.more, in: section)) - } -#endif + #if os(tvOS) + if !rowItems.isEmpty, + section.viewModelProperties.canOpenPage || ApplicationSettingSectionWideSupportEnabled(), + section.viewModelProperties.hasMoreRowItem { + rowItems.append(Item(.more, in: section)) + } + #endif return rowItems } } @@ -629,23 +641,23 @@ protocol PageViewModelProperties { } extension PageViewModelProperties { -#if os(tvOS) - var hasMoreRowItem: Bool { - switch layout { - case .mediaSwimlane, .showSwimlane, .elementSwimlane: - return true - default: - return false + #if os(tvOS) + var hasMoreRowItem: Bool { + switch layout { + case .mediaSwimlane, .showSwimlane, .elementSwimlane: + true + default: + false + } } - } -#endif - + #endif + var hasLoadMore: Bool { switch layout { case .mediaGrid, .mediaList, .showGrid, .liveMediaGrid: - return true + true default: - return false + false } } } @@ -653,11 +665,11 @@ extension PageViewModelProperties { private extension PageViewModel { struct ContentSectionProperties: PageViewModelProperties { let contentSection: SRGContentSection - + private var presentation: SRGContentPresentation { - return contentSection.presentation + contentSection.presentation } - + var layout: PageViewModel.SectionLayout { switch presentation.type { case .heroStage: @@ -672,10 +684,10 @@ private extension PageViewModel { return .elementSwimlane case .topicSelector: return .topicSelector -#if os(iOS) - case .showAccess: - return .showAccess -#endif + #if os(iOS) + case .showAccess: + return .showAccess + #endif case .favoriteShows: return .showSwimlane case .swimlane: @@ -683,67 +695,67 @@ private extension PageViewModel { case .grid: return (contentSection.type == .shows) ? .showGrid : .mediaGrid case .availableEpisodes: -#if os(iOS) - return .mediaList -#else - return .mediaGrid -#endif + #if os(iOS) + return .mediaList + #else + return .mediaGrid + #endif case .livestreams: return .liveMediaSwimlane default: return .mediaSwimlane } } - + var canOpenPage: Bool { switch presentation.type { case .favoriteShows, .myProgram, .continueWatching, .topicSelector, .watchLater: - return true + true default: if presentation.contentLink != nil { - return true + true } else { - return false + false } } } } - + struct ConfiguredSectionProperties: PageViewModelProperties { let configuredSection: ConfiguredSection - let index: Int // TODO: Remove when all pages are configured with PAC - + let index: Int // TODO: Remove when all pages are configured with PAC + var layout: PageViewModel.SectionLayout { switch configuredSection { case .radioLatestEpisodes, .radioMostPopular, .radioLatest, .radioLatestVideos: return index == 0 ? .headline : .mediaSwimlane case .tvLive, .radioLive, .radioLiveSatellite: -#if os(iOS) - return .liveMediaGrid -#else - return .liveMediaSwimlane -#endif + #if os(iOS) + return .liveMediaGrid + #else + return .liveMediaSwimlane + #endif case .favoriteShows, .radioFavoriteShows: return .showSwimlane case .radioAllShows, .tvAllShows: return .showGrid -#if os(iOS) - case .radioShowAccess: - return .showAccess -#endif + #if os(iOS) + case .radioShowAccess: + return .showAccess + #endif case .availableEpisodes: -#if os(iOS) - return .mediaList -#else - return .mediaGrid -#endif + #if os(iOS) + return .mediaList + #else + return .mediaGrid + #endif default: return .mediaSwimlane } } - + var canOpenPage: Bool { - return layout == .mediaSwimlane || layout == .showSwimlane + layout == .mediaSwimlane || layout == .showSwimlane } } } diff --git a/Application/Sources/Content/Publishers.swift b/Application/Sources/Content/Publishers.swift index ffbc31b6f..c8d7d2707 100644 --- a/Application/Sources/Content/Publishers.swift +++ b/Application/Sources/Content/Publishers.swift @@ -15,7 +15,7 @@ extension NotificationCenter { func weakPublisher(for name: Notification.Name, object: AnyObject? = nil) -> AnyPublisher { publisher(for: name) .filter { [weak object] notification in - guard let object = object else { return true } + guard let object else { return true } guard let notificationObject = notification.object as? AnyObject else { return false } return notificationObject === object } @@ -26,74 +26,71 @@ extension NotificationCenter { extension SRGDataProvider { /// Publishes the latest 30 episodes for a show URN list. func latestMediasForShowsPublisher(withUrns urns: [String], pageSize: UInt = SRGDataProviderDefaultPageSize) -> AnyPublisher<[SRGMedia], Error> { - return urns.publisher + urns.publisher .collect(3) .flatMap { urns in - return self.latestMediasForShows(withUrns: urns, filter: .episodesOnly, pageSize: 15) + self.latestMediasForShows(withUrns: urns, filter: .episodesOnly, pageSize: 15) } .reduce([]) { $0 + $1 } .map { medias in - return Array(medias.sorted(by: { $0.publicationDate > $1.publicationDate }).prefix(Int(pageSize))) + Array(medias.sorted(by: { $0.publicationDate > $1.publicationDate }).prefix(Int(pageSize))) } .eraseToAnyPublisher() } - -#if os(iOS) - /// Publishes the regional media which corresponds to the specified media, if any. - private func regionalizedRadioLivestreamMedia(for media: SRGMedia) -> AnyPublisher { - if let channelUid = media.channel?.uid, - let selectedLivestreamUrn = ApplicationSettingSelectedLivestreamURNForChannelUid(channelUid), - media.urn != selectedLivestreamUrn { - return self.radioLivestreams(for: media.vendor, channelUid: channelUid) - .map { medias in - if let selectedMedia = ApplicationSettingSelectedLivestreamMediaForChannelUid(channelUid, medias) { - return selectedMedia - } - else { - return media + + #if os(iOS) + /// Publishes the regional media which corresponds to the specified media, if any. + private func regionalizedRadioLivestreamMedia(for media: SRGMedia) -> AnyPublisher { + if let channelUid = media.channel?.uid, + let selectedLivestreamUrn = ApplicationSettingSelectedLivestreamURNForChannelUid(channelUid), + media.urn != selectedLivestreamUrn { + radioLivestreams(for: media.vendor, channelUid: channelUid) + .map { medias in + if let selectedMedia = ApplicationSettingSelectedLivestreamMediaForChannelUid(channelUid, medias) { + selectedMedia + } else { + media + } } - } - .replaceError(with: media) - .eraseToAnyPublisher() - } - else { - return Just(media) - .eraseToAnyPublisher() + .replaceError(with: media) + .eraseToAnyPublisher() + } else { + Just(media) + .eraseToAnyPublisher() + } } - } -#endif - + #endif + /// Publishes radio livestreams, replacing regional radio channels. Updates are published down the pipeline as they /// are retrieved. func regionalizedRadioLivestreams(for vendor: SRGVendor, contentProviders: SRGContentProviders = .default) -> AnyPublisher<[SRGMedia], Error> { -#if os(iOS) - return radioLivestreams(for: vendor, contentProviders: contentProviders) - .map { medias in - return Publishers.AccumulateLatestMany(medias.map { media in - return self.regionalizedRadioLivestreamMedia(for: media) - }) - .setFailureType(to: Error.self) + #if os(iOS) + return radioLivestreams(for: vendor, contentProviders: contentProviders) + .map { medias in + Publishers.AccumulateLatestMany(medias.map { media in + self.regionalizedRadioLivestreamMedia(for: media) + }) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + .switchToLatest() .eraseToAnyPublisher() - } - .switchToLatest() - .eraseToAnyPublisher() -#else - return radioLivestreams(for: vendor, contentProviders: contentProviders) - .eraseToAnyPublisher() -#endif + #else + return radioLivestreams(for: vendor, contentProviders: contentProviders) + .eraseToAnyPublisher() + #endif } - + func historyEntriesPublisher() -> AnyPublisher<[String], Error> { // Use a deferred future to make it repeatable on-demand // See https://heckj.github.io/swiftui-notes/#reference-future - return Deferred { + Deferred { Future<[String], Error> { promise in let sortDescriptor = NSSortDescriptor(keyPath: \SRGHistoryEntry.date, ascending: false) SRGUserData.current!.history.historyEntries(matching: nil, sortedWith: [sortDescriptor]) { historyEntries, error in if let error { promise(.failure(error)) - } - else { + } else { promise(.success(historyEntries?.compactMap(\.uid) ?? [])) } } @@ -101,21 +98,21 @@ extension SRGDataProvider { } .eraseToAnyPublisher() } - + func historyPublisher(pageSize: UInt = SRGDataProviderDefaultPageSize, paginatedBy paginator: Trigger.Signal?, filter: SectionFiltering?) -> AnyPublisher<[SRGMedia], Error> { - return historyEntriesPublisher() + historyEntriesPublisher() .map { urns in - return self.medias(withUrns: urns, pageSize: pageSize, paginatedBy: paginator) + self.medias(withUrns: urns, pageSize: pageSize, paginatedBy: paginator) .map { filter?.compatibleMedias($0) ?? $0 } } .switchToLatest() .eraseToAnyPublisher() } - + func resumePlaybackPublisher(pageSize: UInt = SRGDataProviderDefaultPageSize, paginatedBy paginator: Trigger.Signal?, filter: SectionFiltering?) -> AnyPublisher<[SRGMedia], Error> { func playbackPositions(for historyEntries: [SRGHistoryEntry]?) -> OrderedDictionary { guard let historyEntries else { return [:] } - + var playbackPositions = OrderedDictionary() for historyEntry in historyEntries { if let uid = historyEntry.uid { @@ -124,7 +121,7 @@ extension SRGDataProvider { } return playbackPositions } - + // Use a deferred future to make it repeatable on-demand // See https://heckj.github.io/swiftui-notes/#reference-future return Deferred { @@ -133,18 +130,17 @@ extension SRGDataProvider { SRGUserData.current!.history.historyEntries(matching: nil, sortedWith: [sortDescriptor]) { historyEntries, error in if let error { promise(.failure(error)) - } - else { + } else { promise(.success(playbackPositions(for: historyEntries))) } } } } .map { playbackPositions in - return self.medias(withUrns: Array(playbackPositions.keys), pageSize: pageSize, paginatedBy: paginator) + self.medias(withUrns: Array(playbackPositions.keys), pageSize: pageSize, paginatedBy: paginator) .map { filter?.compatibleMedias($0) ?? $0 } .map { - return $0.filter { media in + $0.filter { media in guard let playbackPosition = playbackPositions[media.urn] else { return true } return HistoryCanResumePlaybackForMediaAndPosition(playbackPosition, media) } @@ -153,18 +149,17 @@ extension SRGDataProvider { .switchToLatest() .eraseToAnyPublisher() } - + func laterEntriesPublisher() -> AnyPublisher<[String], Error> { // Use a deferred future to make it repeatable on-demand // See https://heckj.github.io/swiftui-notes/#reference-future - return Deferred { + Deferred { Future<[String], Error> { promise in let sortDescriptor = NSSortDescriptor(keyPath: \SRGPlaylistEntry.date, ascending: false) SRGUserData.current!.playlists.playlistEntriesInPlaylist(withUid: SRGPlaylistUid.watchLater.rawValue, matching: nil, sortedWith: [sortDescriptor]) { playlistEntries, error in if let error { promise(.failure(error)) - } - else { + } else { promise(.success(playlistEntries?.compactMap(\.uid) ?? [])) } } @@ -172,20 +167,20 @@ extension SRGDataProvider { } .eraseToAnyPublisher() } - + func laterPublisher(pageSize: UInt = SRGDataProviderDefaultPageSize, paginatedBy paginator: Trigger.Signal?, filter: SectionFiltering?) -> AnyPublisher<[SRGMedia], Error> { - return laterEntriesPublisher() + laterEntriesPublisher() .map { urns in - return self.medias(withUrns: urns, pageSize: pageSize, paginatedBy: paginator) + self.medias(withUrns: urns, pageSize: pageSize, paginatedBy: paginator) .map { filter?.compatibleMedias($0) ?? $0 } } .switchToLatest() .eraseToAnyPublisher() } - + func showsPublisher(withUrns urns: [String]) -> AnyPublisher<[SRGShow], Error> { let trigger = Trigger() - + return shows(withUrns: urns, pageSize: 50 /* Use largest page size */, paginatedBy: trigger.signal(activatedBy: 1)) .handleEvents(receiveOutput: { _ in // FIXME: There is probably a better way @@ -197,21 +192,20 @@ extension SRGDataProvider { .map { $0.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } } .eraseToAnyPublisher() } - + func favoritesPublisher(filter: SectionFiltering?) -> AnyPublisher<[SRGShow], Error> { - return self.showsPublisher(withUrns: FavoritesShowURNs().array as? [String] ?? []) + showsPublisher(withUrns: FavoritesShowURNs().array as? [String] ?? []) .map { filter?.compatibleShows($0) ?? $0 } .eraseToAnyPublisher() } - + func tvProgramsPublisher(day: SRGDay? = nil, mainProvider: Bool, minimal: Bool = false) -> AnyPublisher<[PlayProgramComposition], Error> { let applicationConfiguration = ApplicationConfiguration.shared if mainProvider { return SRGDataProvider.current!.tvPrograms(for: applicationConfiguration.vendor, day: day, minimal: minimal) - .map { Array($0.map({ PlayProgramComposition(channel: $0.channel, programs: $0.programs, external: false) })) } + .map { Array($0.map { PlayProgramComposition(channel: $0.channel, programs: $0.programs, external: false) }) } .eraseToAnyPublisher() - } - else { + } else { let tvOtherPartyProgramsPublishers = applicationConfiguration.tvGuideOtherBouquets .map { tvOtherPartyProgramsPublisher(day: day, bouquet: $0, minimal: minimal) } return Publishers.concatenateMany(tvOtherPartyProgramsPublishers) @@ -219,24 +213,24 @@ extension SRGDataProvider { .eraseToAnyPublisher() } } - + private func tvOtherPartyProgramsPublisher(day: SRGDay? = nil, bouquet: TVGuideBouquet, minimal: Bool = false) -> AnyPublisher<[PlayProgramComposition], Error> { switch bouquet { case .RSI: - return SRGDataProvider.current!.tvPrograms(for: .RSI, day: day, minimal: minimal) - .map { Array($0.map({ PlayProgramComposition(channel: $0.channel, programs: $0.programs, external: false) })) } + SRGDataProvider.current!.tvPrograms(for: .RSI, day: day, minimal: minimal) + .map { Array($0.map { PlayProgramComposition(channel: $0.channel, programs: $0.programs, external: false) }) } .eraseToAnyPublisher() case .RTS: - return SRGDataProvider.current!.tvPrograms(for: .RTS, day: day, minimal: minimal) - .map { Array($0.map({ PlayProgramComposition(channel: $0.channel, programs: $0.programs, external: false) })) } + SRGDataProvider.current!.tvPrograms(for: .RTS, day: day, minimal: minimal) + .map { Array($0.map { PlayProgramComposition(channel: $0.channel, programs: $0.programs, external: false) }) } .eraseToAnyPublisher() case .SRF: - return SRGDataProvider.current!.tvPrograms(for: .SRF, day: day, minimal: minimal) - .map { Array($0.map({ PlayProgramComposition(channel: $0.channel, programs: $0.programs, external: false) })) } + SRGDataProvider.current!.tvPrograms(for: .SRF, day: day, minimal: minimal) + .map { Array($0.map { PlayProgramComposition(channel: $0.channel, programs: $0.programs, external: false) }) } .eraseToAnyPublisher() case .thirdParty: - return SRGDataProvider.current!.tvPrograms(for: ApplicationConfiguration.shared.vendor, provider: .thirdParty, day: day, minimal: minimal) - .map { Array($0.map({ PlayProgramComposition(channel: $0.channel, programs: $0.programs, external: true) })) } + SRGDataProvider.current!.tvPrograms(for: ApplicationConfiguration.shared.vendor, provider: .thirdParty, day: day, minimal: minimal) + .map { Array($0.map { PlayProgramComposition(channel: $0.channel, programs: $0.programs, external: true) }) } .eraseToAnyPublisher() } } @@ -246,7 +240,7 @@ extension SRGDataProvider { struct PlayProgramComposition: Hashable { let channel: PlayChannel let programs: [SRGProgram]? - + init(channel: SRGChannel, programs: [SRGProgram]?, external: Bool) { self.channel = PlayChannel(wrappedValue: channel, external: external) self.programs = programs @@ -260,7 +254,7 @@ struct PlayChannel: Hashable { extension Publishers { static func concatenateMany(_ publishers: [AnyPublisher]) -> AnyPublisher { - return publishers.reduce(Empty().eraseToAnyPublisher()) { acc, elem in + publishers.reduce(Empty().eraseToAnyPublisher()) { acc, elem in Publishers.Concatenate(prefix: acc, suffix: elem).eraseToAnyPublisher() } } @@ -272,10 +266,10 @@ enum UserDataPublishers { case unsubscribed case subscribed } - + static func playbackProgressPublisher(for media: SRGMedia) -> AnyPublisher { - return Publishers.PublishAndRepeat(onOutputFrom: ThrottledSignal.historyUpdates(for: media.urn)) { - return Deferred { + Publishers.PublishAndRepeat(onOutputFrom: ThrottledSignal.historyUpdates(for: media.urn)) { + Deferred { Future { promise in HistoryPlaybackProgressForMediaAsync(media) { progress, completed in guard completed else { return } @@ -288,19 +282,19 @@ enum UserDataPublishers { .prepend(nil) .eraseToAnyPublisher() } - + static func favoritePublisher(for show: SRGShow) -> AnyPublisher { - return ThrottledSignal.preferenceUpdates(interval: 0) + ThrottledSignal.preferenceUpdates(interval: 0) .prepend(()) .map { _ in - return FavoritesContainsShow(show) + FavoritesContainsShow(show) } .eraseToAnyPublisher() } - + static func laterAllowedActionPublisher(for media: SRGMedia) -> AnyPublisher { - return Publishers.PublishAndRepeat(onOutputFrom: ThrottledSignal.watchLaterUpdates(for: media.urn)) { - return Deferred { + Publishers.PublishAndRepeat(onOutputFrom: ThrottledSignal.watchLaterUpdates(for: media.urn)) { + Deferred { Future { promise in WatchLaterAllowedActionForMediaAsync(media) { action in promise(.success(action)) @@ -311,39 +305,39 @@ enum UserDataPublishers { .prepend(.none) .eraseToAnyPublisher() } - -#if os(iOS) - static func subscriptionStatusPublisher(for show: SRGShow) -> AnyPublisher { - return Publishers.Merge( - ThrottledSignal.preferenceUpdates(interval: 0), - ApplicationSignal.pushServiceStatusUpdate() - ) - .prepend(()) - .map { - guard let isEnabled = PushService.shared?.isEnabled, isEnabled else { return .unavailable } - return FavoritesIsSubscribedToShow(show) ? .subscribed : .unsubscribed + + #if os(iOS) + static func subscriptionStatusPublisher(for show: SRGShow) -> AnyPublisher { + Publishers.Merge( + ThrottledSignal.preferenceUpdates(interval: 0), + ApplicationSignal.pushServiceStatusUpdate() + ) + .prepend(()) + .map { + guard let isEnabled = PushService.shared?.isEnabled, isEnabled else { return .unavailable } + return FavoritesIsSubscribedToShow(show) ? .subscribed : .unsubscribed + } + .eraseToAnyPublisher() } - .eraseToAnyPublisher() - } -#endif + #endif } #if DEBUG -extension Publisher { - /** - * Dump values passing through the pipeline. - * - * Borrowed from https://peterfriese.dev/posts/swiftui-combine-custom-operators/ - */ - func dump() -> AnyPublisher { - handleEvents { output in - Swift.dump(output) - } receiveCompletion: { completion in - if case let .failure(error) = completion { - Swift.dump(error) + extension Publisher { + /** + * Dump values passing through the pipeline. + * + * Borrowed from https://peterfriese.dev/posts/swiftui-combine-custom-operators/ + */ + func dump() -> AnyPublisher { + handleEvents { output in + Swift.dump(output) + } receiveCompletion: { completion in + if case let .failure(error) = completion { + Swift.dump(error) + } } + .eraseToAnyPublisher() } - .eraseToAnyPublisher() } -} #endif diff --git a/Application/Sources/Content/SectionShowHeaderView.swift b/Application/Sources/Content/SectionShowHeaderView.swift index b5b08c986..6526023c2 100644 --- a/Application/Sources/Content/SectionShowHeaderView.swift +++ b/Application/Sources/Content/SectionShowHeaderView.swift @@ -16,12 +16,12 @@ import SwiftUI class OpenShowEvent: UIEvent { let show: SRGShow - + init(show: SRGShow) { self.show = show super.init() } - + override init() { fatalError("init() is not available") } @@ -33,19 +33,19 @@ class OpenShowEvent: UIEvent { struct SectionShowHeaderView: View { let section: Content.Section let show: SRGShow - + fileprivate static let verticalSpacing: CGFloat = constant(iOS: 18, tvOS: 24) - + @Environment(\.uiHorizontalSizeClass) private var horizontalSizeClass - + private var direction: StackDirection { - return (horizontalSizeClass == .compact) ? .vertical : .horizontal + (horizontalSizeClass == .compact) ? .vertical : .horizontal } - + private var alignment: StackAlignment { - return (horizontalSizeClass == .compact) ? .center : .leading + (horizontalSizeClass == .compact) ? .center : .leading } - + var body: some View { Stack(direction: direction, alignment: alignment, spacing: 0) { ShowVisualView(show: show, size: .medium) @@ -64,22 +64,22 @@ struct SectionShowHeaderView: View { .padding(.bottom, constant(iOS: 20, tvOS: 50)) .focusable() } - + /// Behavior: h-exp, v-exp private struct ImageOverlay: View { let horizontalSizeClass: UIUserInterfaceSizeClass - + var body: some View { if horizontalSizeClass == .regular { LinearGradient(colors: [.clear, .srgGray16], startPoint: .center, endPoint: .trailing) } } } - + /// Behavior: h-hug, v-hug private struct DescriptionView: View { let section: Content.Section - + var body: some View { VStack(spacing: SectionShowHeaderView.verticalSpacing) { if let title = section.properties.title { @@ -105,22 +105,22 @@ struct SectionShowHeaderView: View { } } } - + /// Behavior: h-hug, v-hug private struct ShowAccessButton: View { let show: SRGShow - + @State private var isFocused = false @FirstResponder private var firstResponder - + var accessibilityLabel: String? { - return show.title + show.title } - + var accessibilityHint: String? { - return PlaySRGAccessibilityLocalizedString("Opens show details.", comment: "Show button hint") + PlaySRGAccessibilityLocalizedString("Opens show details.", comment: "Show button hint") } - + var body: some View { SimpleButton(icon: .episodes, label: show.title) { firstResponder.sendAction(#selector(SectionShowHeaderViewAction.openShow(sender:event:)), for: OpenShowEvent(show: show)) @@ -135,11 +135,10 @@ struct SectionShowHeaderView: View { private extension View { func adaptiveMainFrame(for horizontalSizeClass: UIUserInterfaceSizeClass?) -> some View { - return Group { + Group { if horizontalSizeClass == .compact { self - } - else { + } else { frame(height: constant(iOS: 200, tvOS: 400), alignment: .top) } } @@ -154,8 +153,7 @@ enum SectionShowHeaderViewSize { let fittingSize = CGSize(width: layoutWidth, height: UIView.layoutFittingExpandedSize.height) let size = SectionShowHeaderView(section: section, show: show).adaptiveSizeThatFits(in: fittingSize, for: horizontalSizeClass) return NSCollectionLayoutSize(widthDimension: .absolute(layoutWidth), heightDimension: .absolute(size.height)) - } - else { + } else { return NSCollectionLayoutSize(widthDimension: .absolute(layoutWidth), heightDimension: .absolute(LayoutHeaderHeightZero)) } } @@ -165,19 +163,19 @@ enum SectionShowHeaderViewSize { struct SectionShowHeaderView_Previews: PreviewProvider { static var previews: some View { -#if os(tvOS) - SectionShowHeaderView(section: .content(Mock.contentSection()), show: Mock.show()) - .previewLayout(.sizeThatFits) -#else - SectionShowHeaderView(section: .content(Mock.contentSection()), show: Mock.show()) - .frame(width: 1000) - .previewLayout(.sizeThatFits) - .environment(\.horizontalSizeClass, .regular) - - SectionShowHeaderView(section: .content(Mock.contentSection()), show: Mock.show()) - .frame(width: 375) - .previewLayout(.sizeThatFits) - .environment(\.horizontalSizeClass, .compact) -#endif + #if os(tvOS) + SectionShowHeaderView(section: .content(Mock.contentSection(), type: Mock.show().play_contentType), show: Mock.show()) + .previewLayout(.sizeThatFits) + #else + SectionShowHeaderView(section: .content(Mock.contentSection(), type: Mock.show().play_contentType), show: Mock.show()) + .frame(width: 1000) + .previewLayout(.sizeThatFits) + .environment(\.horizontalSizeClass, .regular) + + SectionShowHeaderView(section: .content(Mock.contentSection(), type: Mock.show().play_contentType), show: Mock.show()) + .frame(width: 375) + .previewLayout(.sizeThatFits) + .environment(\.horizontalSizeClass, .compact) + #endif } } diff --git a/Application/Sources/Content/SectionViewController.swift b/Application/Sources/Content/SectionViewController.swift index a694ad92e..25e254021 100644 --- a/Application/Sources/Content/SectionViewController.swift +++ b/Application/Sources/Content/SectionViewController.swift @@ -16,35 +16,35 @@ final class SectionViewController: UIViewController { let model: SectionViewModel var initialSectionId: String? let fromPushNotification: Bool - + private static let itemSpacing: CGFloat = constant(iOS: 8, tvOS: 40) private static let layoutHorizontalMargin: CGFloat = constant(iOS: 16, tvOS: 0) private static let layoutVerticalMargin: CGFloat = constant(iOS: 8, tvOS: 0) - + private var cancellables = Set() - + private var dataSource: UICollectionViewDiffableDataSource! - + private weak var collectionView: UICollectionView! private weak var emptyContentView: HostView! - -#if os(iOS) - private weak var refreshControl: UIRefreshControl! - - private var refreshTriggered = false -#endif - + + #if os(iOS) + private weak var refreshControl: UIRefreshControl! + + private var refreshTriggered = false + #endif + private var contentInsets: UIEdgeInsets private var leftBarButtonItem: UIBarButtonItem? - + private var headerTitle: String? { -#if os(tvOS) - return (tabBarController == nil && model.displaysTitle) ? model.title : nil -#else - return nil -#endif + #if os(tvOS) + return (tabBarController == nil && model.displaysTitle) ? model.title : nil + #else + return nil + #endif } - + private static func snapshot(from state: SectionViewModel.State) -> NSDiffableDataSourceSnapshot { var snapshot = NSDiffableDataSourceSnapshot() if case let .loaded(rows: rows) = state { @@ -55,7 +55,7 @@ final class SectionViewController: UIViewController { } return snapshot } - + /** * Use `initialSectionId` to provide the collection view section id where the view should initially open. If not found or * specified the view opens at its top. @@ -68,22 +68,23 @@ final class SectionViewController: UIViewController { super.init(nibName: nil, bundle: nil) title = model.displaysTitle ? model.title : nil } - - required init?(coder: NSCoder) { + + @available(*, unavailable) + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func loadView() { let view = UIView(frame: UIScreen.main.bounds) view.backgroundColor = .srgGray16 - + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout()) collectionView.delegate = self collectionView.backgroundColor = .clear collectionView.allowsMultipleSelectionDuringEditing = true view.addSubview(collectionView) self.collectionView = collectionView - + collectionView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ collectionView.topAnchor.constraint(equalTo: view.topAnchor), @@ -91,38 +92,38 @@ final class SectionViewController: UIViewController { collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) - -#if os(iOS) - if #available(iOS 17.0, *) { - collectionView.registerForTraitChanges([UITraitHorizontalSizeClass.self]) { (collectionView: UICollectionView, _) in - collectionView.collectionViewLayout.invalidateLayout() + + #if os(iOS) + if #available(iOS 17.0, *) { + collectionView.registerForTraitChanges([UITraitHorizontalSizeClass.self]) { (collectionView: UICollectionView, _) in + collectionView.collectionViewLayout.invalidateLayout() + } } - } -#endif - + #endif + let emptyContentView = HostView(frame: .zero) collectionView.backgroundView = emptyContentView self.emptyContentView = emptyContentView - -#if os(tvOS) - tabBarObservedScrollView = collectionView -#else - let refreshControl = RefreshControl() - refreshControl.addTarget(self, action: #selector(pullToRefresh), for: .valueChanged) - collectionView.insertSubview(refreshControl, at: 0) - self.refreshControl = refreshControl -#endif + + #if os(tvOS) + tabBarObservedScrollView = collectionView + #else + let refreshControl = RefreshControl() + refreshControl.addTarget(self, action: #selector(pullToRefresh), for: .valueChanged) + collectionView.insertSubview(refreshControl, at: 0) + self.refreshControl = refreshControl + #endif self.view = view } - + override func viewDidLoad() { super.viewDidLoad() - -#if os(iOS) - navigationItem.largeTitleDisplayMode = model.configuration.viewModelProperties.largeTitleDisplayMode - updateNavigationBar() -#endif - + + #if os(iOS) + navigationItem.largeTitleDisplayMode = model.configuration.viewModelProperties.largeTitleDisplayMode + updateNavigationBar() + #endif + let cellRegistration = UICollectionView.CellRegistration, SectionViewModel.Item> { [weak self] cell, indexPath, item in guard let self else { return } let section = dataSource.snapshot().sectionIdentifiers[indexPath.section] @@ -132,11 +133,11 @@ final class SectionViewController: UIViewController { addChild(hostController) } } - + dataSource = IndexedCollectionViewDiffableDataSource(collectionView: collectionView, minimumIndexTitlesCount: 4) { collectionView, indexPath, item in - return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item) + collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item) } - + let titleHeaderViewRegistration = UICollectionView.SupplementaryRegistration>(elementKind: Header.titleHeader.rawValue) { [weak self] view, _, _ in guard let self else { return } view.content = TitleHeaderView(headerTitle, titleTextAlignment: constant(iOS: .leading, tvOS: .center)) @@ -144,7 +145,7 @@ final class SectionViewController: UIViewController { addChild(hostController) } } - + let sectionHeaderViewRegistration = UICollectionView.SupplementaryRegistration>(elementKind: UICollectionView.elementKindSectionHeader) { [weak self] view, _, indexPath in guard let self else { return } let snapshot = dataSource.snapshot() @@ -154,7 +155,7 @@ final class SectionViewController: UIViewController { addChild(hostController) } } - + let sectionFooterViewRegistration = UICollectionView.SupplementaryRegistration>(elementKind: UICollectionView.elementKindSectionFooter) { [weak self] view, _, indexPath in guard let self else { return } let snapshot = dataSource.snapshot() @@ -164,123 +165,119 @@ final class SectionViewController: UIViewController { addChild(hostController) } } - + dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in switch kind { case Header.titleHeader.rawValue: - return collectionView.dequeueConfiguredReusableSupplementary(using: titleHeaderViewRegistration, for: indexPath) + collectionView.dequeueConfiguredReusableSupplementary(using: titleHeaderViewRegistration, for: indexPath) case UICollectionView.elementKindSectionHeader: - return collectionView.dequeueConfiguredReusableSupplementary(using: sectionHeaderViewRegistration, for: indexPath) + collectionView.dequeueConfiguredReusableSupplementary(using: sectionHeaderViewRegistration, for: indexPath) case UICollectionView.elementKindSectionFooter: - return collectionView.dequeueConfiguredReusableSupplementary(using: sectionFooterViewRegistration, for: indexPath) + collectionView.dequeueConfiguredReusableSupplementary(using: sectionFooterViewRegistration, for: indexPath) default: - return nil + nil } } - + model.$state .sink { [weak self] state in self?.reloadData(for: state) } .store(in: &cancellables) } - + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - + updateLayoutConfiguration() - + model.resetApplicationBadgeIfNeeded() model.reload() deselectItems(in: collectionView, animated: animated) navigationController?.setNavigationBarHidden(false, animated: animated) } - + private func updateLayoutConfiguration() { - if let collectionViewLayout = self.collectionView.collectionViewLayout as? UICollectionViewCompositionalLayout { + if let collectionViewLayout = collectionView.collectionViewLayout as? UICollectionViewCompositionalLayout { collectionViewLayout.configuration = Self.layoutConfiguration(title: headerTitle, layoutWidth: view.safeAreaLayoutGuide.layoutFrame.width, horizontalSizeClass: view.traitCollection.horizontalSizeClass) } } - -#if os(iOS) - override func setEditing(_ editing: Bool, animated: Bool) { - super.setEditing(editing, animated: animated) - - collectionView.isEditing = editing - - if isEditing { - leftBarButtonItem = navigationItem.leftBarButtonItem - } - else { - leftBarButtonItem = nil - model.clearSelection() - } - - // Force a cell global appearance update - collectionView.reloadData() - - updateNavigationBar() - } - - private func updateNavigationBar(for state: SectionViewModel.State) { - if model.configuration.properties.supportsEdition && state.hasContent { - navigationItem.rightBarButtonItem = editButtonItem - + + #if os(iOS) + override func setEditing(_ editing: Bool, animated: Bool) { + super.setEditing(editing, animated: animated) + + collectionView.isEditing = editing + if isEditing { - navigationItem.title = Self.title(for: model.numberOfSelectedItems) - editButtonItem.title = NSLocalizedString("Done", comment: "Done button title") - - let numberOfSelectedItems = model.numberOfSelectedItems - let deleteBarButtonItem = UIBarButtonItem(image: UIImage(resource: .delete), style: .plain, target: self, action: #selector(deleteSelectedItems)) - deleteBarButtonItem.tintColor = .red - deleteBarButtonItem.isEnabled = (numberOfSelectedItems != 0) - deleteBarButtonItem.accessibilityLabel = PlaySRGAccessibilityLocalizedString("Delete", comment: "Delete button label") - deleteBarButtonItem.accessibilityValue = (numberOfSelectedItems != 0) ? Self.title(for: numberOfSelectedItems) : nil - navigationItem.leftBarButtonItem = deleteBarButtonItem + leftBarButtonItem = navigationItem.leftBarButtonItem + } else { + leftBarButtonItem = nil + model.clearSelection() } - else { + + // Force a cell global appearance update + collectionView.reloadData() + + updateNavigationBar() + } + + private func updateNavigationBar(for state: SectionViewModel.State) { + if model.configuration.properties.supportsEdition, state.hasContent { + navigationItem.rightBarButtonItem = editButtonItem + + if isEditing { + navigationItem.title = Self.title(for: model.numberOfSelectedItems) + editButtonItem.title = NSLocalizedString("Done", comment: "Done button title") + + let numberOfSelectedItems = model.numberOfSelectedItems + let deleteBarButtonItem = UIBarButtonItem(image: UIImage(resource: .delete), style: .plain, target: self, action: #selector(deleteSelectedItems)) + deleteBarButtonItem.tintColor = .red + deleteBarButtonItem.isEnabled = (numberOfSelectedItems != 0) + deleteBarButtonItem.accessibilityLabel = PlaySRGAccessibilityLocalizedString("Delete", comment: "Delete button label") + deleteBarButtonItem.accessibilityValue = (numberOfSelectedItems != 0) ? Self.title(for: numberOfSelectedItems) : nil + navigationItem.leftBarButtonItem = deleteBarButtonItem + } else { + navigationItem.title = model.displaysTitle ? model.title : nil + editButtonItem.title = NSLocalizedString("Select", comment: "Select button title") + navigationItem.leftBarButtonItem = leftBarButtonItem + } + } else { navigationItem.title = model.displaysTitle ? model.title : nil - editButtonItem.title = NSLocalizedString("Select", comment: "Select button title") + + if model.configuration.properties.sharingItem != nil { + let shareButtonItem = UIBarButtonItem(image: UIImage(resource: .share), + style: .plain, + target: self, + action: #selector(shareContent(_:))) + shareButtonItem.accessibilityLabel = PlaySRGAccessibilityLocalizedString("Share", comment: "Share button label on section detail view") + navigationItem.rightBarButtonItem = shareButtonItem + } else { + navigationItem.rightBarButtonItem = nil + } + navigationItem.leftBarButtonItem = leftBarButtonItem } } - else { - navigationItem.title = model.displaysTitle ? model.title : nil - - if model.configuration.properties.sharingItem != nil { - let shareButtonItem = UIBarButtonItem(image: UIImage(resource: .share), - style: .plain, - target: self, - action: #selector(self.shareContent(_:))) - shareButtonItem.accessibilityLabel = PlaySRGAccessibilityLocalizedString("Share", comment: "Share button label on section detail view") - navigationItem.rightBarButtonItem = shareButtonItem - } - else { - navigationItem.rightBarButtonItem = nil + + private func updateNavigationBar() { + updateNavigationBar(for: model.state) + } + + private static func title(for numberOfSelectedItems: Int) -> String { + // TODO: Should use plural localization here but a bit costly (and not sure it is well integrated with CrowdIn) + // See https://developer.apple.com/documentation/xcode/localizing-strings-that-contain-plurals + switch numberOfSelectedItems { + case 0: + NSLocalizedString("Select items", comment: "Title displayed when no item has been selected") + case 1: + NSLocalizedString("1 item", comment: "Title displayed when 1 item has been selected") + default: + String(format: NSLocalizedString("%d items", comment: "Title displayed when several items have been selected"), numberOfSelectedItems) } - - navigationItem.leftBarButtonItem = leftBarButtonItem - } - } - - private func updateNavigationBar() { - updateNavigationBar(for: model.state) - } - - private static func title(for numberOfSelectedItems: Int) -> String { - // TODO: Should use plural localization here but a bit costly (and not sure it is well integrated with CrowdIn) - // See https://developer.apple.com/documentation/xcode/localizing-strings-that-contain-plurals - switch numberOfSelectedItems { - case 0: - return NSLocalizedString("Select items", comment: "Title displayed when no item has been selected") - case 1: - return NSLocalizedString("1 item", comment: "Title displayed when 1 item has been selected") - default: - return String(format: NSLocalizedString("%d items", comment: "Title displayed when several items have been selected"), numberOfSelectedItems) } - } -#endif - + #endif + private func reloadData(for state: SectionViewModel.State) { switch state { case .loading: @@ -291,85 +288,85 @@ final class SectionViewController: UIViewController { let properties = model.configuration.properties emptyContentView.content = state.displaysEmptyContentView ? EmptyContentView(state: .empty(type: properties.emptyType)) : nil } - -#if os(iOS) - updateNavigationBar(for: state) -#endif - + + #if os(iOS) + updateNavigationBar(for: state) + #endif + contentInsets = Self.contentInsets(for: state) play_setNeedsContentInsetsUpdate() - + DispatchQueue.global(qos: .userInteractive).async { // Can be triggered on a background thread. Layout is updated on the main thread. self.dataSource.apply(Self.snapshot(from: state)) { -#if os(iOS) - self.collectionView.reloadSectionIndexBar() - - // Apply colors when the section bar might be visible. - self.collectionView.setSectionBarAppearance(indexColor: .srgGray96, - indexBackgroundColor: .init(white: 0, alpha: 0.3)) - self.scrollToInitialSection() - - // Avoid stopping scrolling. - // See http://stackoverflow.com/a/31681037/760435 - if self.refreshControl.isRefreshing { - self.refreshControl.endRefreshing() - } -#endif + #if os(iOS) + self.collectionView.reloadSectionIndexBar() + + // Apply colors when the section bar might be visible. + self.collectionView.setSectionBarAppearance(indexColor: .srgGray96, + indexBackgroundColor: .init(white: 0, alpha: 0.3)) + self.scrollToInitialSection() + + // Avoid stopping scrolling. + // See http://stackoverflow.com/a/31681037/760435 + if self.refreshControl.isRefreshing { + self.refreshControl.endRefreshing() + } + #endif } } } - + private func scrollToInitialSection() { guard initialSectionId != nil else { return } - + let sectionIdentifiers = dataSource.snapshot().sectionIdentifiers guard !sectionIdentifiers.isEmpty else { return } - + if let index = sectionIdentifiers.firstIndex(where: { $0.id == initialSectionId }) { collectionView.play_scrollToItem(at: IndexPath(row: 0, section: index), at: .top, animated: true) } initialSectionId = nil } - + private static func contentInsets(for state: SectionViewModel.State) -> UIEdgeInsets { let top = (state.headerSize == .zero) ? Self.layoutVerticalMargin : 0 let bottom = Self.layoutVerticalMargin return UIEdgeInsets(top: top, left: 0, bottom: bottom, right: 0) } - -#if os(iOS) - @objc private func pullToRefresh(_ refreshControl: RefreshControl) { - if refreshControl.isRefreshing { - refreshControl.endRefreshing() - } - refreshTriggered = true - } - - @objc private func shareContent(_ barButtonItem: UIBarButtonItem) { - guard let sharingItem = model.configuration.properties.sharingItem else { return } - - let activityViewController = UIActivityViewController(sharingItem: sharingItem, from: .button) - activityViewController.modalPresentationStyle = .popover - - let popoverPresentationController = activityViewController.popoverPresentationController - popoverPresentationController?.barButtonItem = barButtonItem - - self.present(activityViewController, animated: true, completion: nil) - } - - @objc private func deleteSelectedItems(_ barButtonItem: UIBarButtonItem) { - let alertController = UIAlertController(title: NSLocalizedString("Delete", comment: "Title of the confirmation pop-up displayed when the user is about to delete items"), - message: NSLocalizedString("The selected items will be deleted.", comment: "Confirmation message displayed when the user is about to delete selected entries"), - preferredStyle: .alert) - alertController.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "Title of a cancel button"), style: .default, handler: nil)) - alertController.addAction(UIAlertAction(title: NSLocalizedString("Delete", comment: "Title of a delete button"), style: .destructive, handler: { _ in - self.model.deleteSelection() - self.setEditing(false, animated: true) - })) - present(alertController, animated: true, completion: nil) - } -#endif + + #if os(iOS) + @objc private func pullToRefresh(_ refreshControl: RefreshControl) { + if refreshControl.isRefreshing { + refreshControl.endRefreshing() + } + refreshTriggered = true + } + + @objc private func shareContent(_ barButtonItem: UIBarButtonItem) { + guard let sharingItem = model.configuration.properties.sharingItem else { return } + + let activityViewController = UIActivityViewController(sharingItem: sharingItem, from: .button) + activityViewController.modalPresentationStyle = .popover + + let popoverPresentationController = activityViewController.popoverPresentationController + popoverPresentationController?.barButtonItem = barButtonItem + + present(activityViewController, animated: true, completion: nil) + } + + @objc private func deleteSelectedItems(_: UIBarButtonItem) { + let alertController = UIAlertController(title: NSLocalizedString("Delete", comment: "Title of the confirmation pop-up displayed when the user is about to delete items"), + message: NSLocalizedString("The selected items will be deleted.", comment: "Confirmation message displayed when the user is about to delete selected entries"), + preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "Title of a cancel button"), style: .default, handler: nil)) + alertController.addAction(UIAlertAction(title: NSLocalizedString("Delete", comment: "Title of a delete button"), style: .destructive, handler: { _ in + self.model.deleteSelection() + self.setEditing(false, animated: true) + })) + present(alertController, animated: true, completion: nil) + } + #endif } // MARK: Types @@ -397,59 +394,57 @@ extension SectionViewController: DailyMediasViewController { return nil } } - + var scrollView: UIScrollView { - return collectionView + collectionView } } extension SectionViewController { - @objc static func viewController(forContentSection contentSection: SRGContentSection) -> SectionViewController { - return SectionViewController(section: .content(contentSection)) + @objc static func viewController(forContentSection contentSection: SRGContentSection, contentType: ContentType) -> SectionViewController { + SectionViewController(section: .content(contentSection, type: contentType)) } - -#if os(iOS) - @objc static func downloadsViewController() -> SectionViewController { - return SectionViewController(section: .configured(.downloads)) - } - - @objc static func notificationsViewController() -> SectionViewController { - return SectionViewController(section: .configured(.notifications)) - } -#endif - + + #if os(iOS) + @objc static func downloadsViewController() -> SectionViewController { + SectionViewController(section: .configured(.downloads)) + } + + @objc static func notificationsViewController() -> SectionViewController { + SectionViewController(section: .configured(.notifications)) + } + #endif + @objc static func favoriteShowsViewController() -> SectionViewController { - return SectionViewController(section: .configured(.favoriteShows)) + SectionViewController(section: .configured(.favoriteShows(contentType: .mixed))) } - + @objc static func historyViewController() -> SectionViewController { - return SectionViewController(section: .configured(.history)) + SectionViewController(section: .configured(.history)) } - + @objc static func watchLaterViewController() -> SectionViewController { - return SectionViewController(section: .configured(.watchLater)) + SectionViewController(section: .configured(.watchLater)) } - + @objc static func mediasViewController(forDay day: SRGDay, channelUid: String?) -> SectionViewController & DailyMediasViewController { if let channelUid { - return SectionViewController(section: .configured(.radioEpisodesForDay(day, channelUid: channelUid))) - } - else { - return SectionViewController(section: .configured(.tvEpisodesForDay(day))) + SectionViewController(section: .configured(.radioEpisodesForDay(day, channelUid: channelUid))) + } else { + SectionViewController(section: .configured(.tvEpisodesForDay(day))) } } - + @objc static func showsViewController(forChannelUid channelUid: String?, initialSectionId: String?) -> SectionViewController { if let channelUid { - return SectionViewController(section: .configured(.radioAllShows(channelUid: channelUid)), initialSectionId: initialSectionId) - } - else { - return SectionViewController(section: .configured(.tvAllShows), initialSectionId: initialSectionId) + SectionViewController(section: .configured(.radioAllShows(channelUid: channelUid)), initialSectionId: initialSectionId) + } else { + SectionViewController(section: .configured(.tvAllShows), initialSectionId: initialSectionId) } } - + @objc static func showsViewController(forChannelUid channelUid: String?) -> SectionViewController { - return showsViewController(forChannelUid: channelUid, initialSectionId: nil) + showsViewController(forChannelUid: channelUid, initialSectionId: nil) } } @@ -457,116 +452,114 @@ extension SectionViewController { extension SectionViewController: ContentInsets { var play_contentScrollViews: [UIScrollView]? { - return collectionView != nil ? [collectionView] : nil + collectionView != nil ? [collectionView] : nil } - + var play_paddingContentInsets: UIEdgeInsets { - return contentInsets + contentInsets } } #if os(iOS) -extension SectionViewController: Oriented { -} + extension SectionViewController: Oriented {} #endif extension SectionViewController: ScrollableContent { var play_scrollableView: UIScrollView? { - return collectionView + collectionView } } extension SectionViewController: UICollectionViewDelegate { -#if os(iOS) - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - let snapshot = dataSource.snapshot() - let section = snapshot.sectionIdentifiers[indexPath.section] - let item = snapshot.itemIdentifiers(inSection: section)[indexPath.row] - - if collectionView.isEditing { - model.select(item) + #if os(iOS) + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + let snapshot = dataSource.snapshot() + let section = snapshot.sectionIdentifiers[indexPath.section] + let item = snapshot.itemIdentifiers(inSection: section)[indexPath.row] + + if collectionView.isEditing { + model.select(item) + updateNavigationBar() + } else { + navigateToItem(item) + } + } + + func collectionView(_: UICollectionView, didDeselectItemAt indexPath: IndexPath) { + let snapshot = dataSource.snapshot() + let section = snapshot.sectionIdentifiers[indexPath.section] + let item = snapshot.itemIdentifiers(inSection: section)[indexPath.row] + + model.deselect(item) updateNavigationBar() } - else { - navigateToItem(item) - } - } - - func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) { - let snapshot = dataSource.snapshot() - let section = snapshot.sectionIdentifiers[indexPath.section] - let item = snapshot.itemIdentifiers(inSection: section)[indexPath.row] - - model.deselect(item) - updateNavigationBar() - } - - func collectionView(_ collectionView: UICollectionView, shouldBeginMultipleSelectionInteractionAt indexPath: IndexPath) -> Bool { - return model.configuration.properties.supportsEdition - } - - func collectionView(_ collectionView: UICollectionView, didBeginMultipleSelectionInteractionAt indexPath: IndexPath) { - if !isEditing { - setEditing(true, animated: true) - } - } - - func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { - guard !collectionView.isEditing else { return nil } - - let snapshot = dataSource.snapshot() - let section = snapshot.sectionIdentifiers[indexPath.section] - let item = snapshot.itemIdentifiers(inSection: section)[indexPath.row] - return ContextMenu.configuration(for: item, at: indexPath, in: self) - } - - func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { - ContextMenu.commitPreview(in: self, animator: animator) - } - - func collectionView(_ collectionView: UICollectionView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { - return preview(for: configuration, in: collectionView) - } - - func collectionView(_ collectionView: UICollectionView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { - return preview(for: configuration, in: collectionView) - } - - private func preview(for configuration: UIContextMenuConfiguration, in collectionView: UICollectionView) -> UITargetedPreview? { - guard let interactionView = ContextMenu.interactionView(in: collectionView, with: configuration) else { return nil } - let parameters = UIPreviewParameters() - parameters.backgroundColor = view.backgroundColor - return UITargetedPreview(view: interactionView, parameters: parameters) - } -#endif - -#if os(tvOS) - func collectionView(_ collectionView: UICollectionView, canFocusItemAt indexPath: IndexPath) -> Bool { - return false - } -#endif + + func collectionView(_: UICollectionView, shouldBeginMultipleSelectionInteractionAt _: IndexPath) -> Bool { + model.configuration.properties.supportsEdition + } + + func collectionView(_: UICollectionView, didBeginMultipleSelectionInteractionAt _: IndexPath) { + if !isEditing { + setEditing(true, animated: true) + } + } + + func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point _: CGPoint) -> UIContextMenuConfiguration? { + guard !collectionView.isEditing else { return nil } + + let snapshot = dataSource.snapshot() + let section = snapshot.sectionIdentifiers[indexPath.section] + let item = snapshot.itemIdentifiers(inSection: section)[indexPath.row] + return ContextMenu.configuration(for: item, at: indexPath, in: self) + } + + func collectionView(_: UICollectionView, willPerformPreviewActionForMenuWith _: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + ContextMenu.commitPreview(in: self, animator: animator) + } + + func collectionView(_ collectionView: UICollectionView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + preview(for: configuration, in: collectionView) + } + + func collectionView(_ collectionView: UICollectionView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + preview(for: configuration, in: collectionView) + } + + private func preview(for configuration: UIContextMenuConfiguration, in collectionView: UICollectionView) -> UITargetedPreview? { + guard let interactionView = ContextMenu.interactionView(in: collectionView, with: configuration) else { return nil } + let parameters = UIPreviewParameters() + parameters.backgroundColor = view.backgroundColor + return UITargetedPreview(view: interactionView, parameters: parameters) + } + #endif + + #if os(tvOS) + func collectionView(_: UICollectionView, canFocusItemAt _: IndexPath) -> Bool { + false + } + #endif } extension SectionViewController: UIScrollViewDelegate { -#if os(iOS) - func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { - // Avoid the collection jumping when pulling to refresh. Only mark the refresh as being triggered. - if refreshTriggered { - model.reload(deep: true) - refreshTriggered = false + #if os(iOS) + func scrollViewDidEndDecelerating(_: UIScrollView) { + // Avoid the collection jumping when pulling to refresh. Only mark the refresh as being triggered. + if refreshTriggered { + model.reload(deep: true) + refreshTriggered = false + } } - } - - // The system default behavior does not lead to correct results when large titles are displayed. Override. - func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { - scrollView.play_scrollToTop(animated: true) - return false - } -#endif - + + // The system default behavior does not lead to correct results when large titles are displayed. Override. + func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { + scrollView.play_scrollToTop(animated: true) + return false + } + #endif + func scrollViewDidScroll(_ scrollView: UIScrollView) { guard scrollView.contentSize.height > 0 else { return } - + let numberOfScreens = 4 if scrollView.contentOffset.y > scrollView.contentSize.height - CGFloat(numberOfScreens) * scrollView.frame.height { model.loadMore() @@ -576,43 +569,43 @@ extension SectionViewController: UIScrollViewDelegate { extension SectionViewController: SRGAnalyticsViewTracking { var srg_pageViewTitle: String { - return model.configuration.properties.analyticsTitle ?? "" + model.configuration.properties.analyticsTitle ?? "" } - + var srg_pageViewType: String { - return model.configuration.properties.analyticsType ?? "" + model.configuration.properties.analyticsType ?? "" } - + var srg_pageViewLevels: [String]? { - return model.configuration.properties.analyticsLevels + model.configuration.properties.analyticsLevels } - + var srg_isOpenedFromPushNotification: Bool { - return fromPushNotification + fromPushNotification } } extension SectionViewController: SectionShowHeaderViewAction { - func openShow(sender: Any?, event: OpenShowEvent?) { + func openShow(sender _: Any?, event: OpenShowEvent?) { guard let event else { return } - -#if os(tvOS) - navigateToShow(event.show) -#else - if let navigationController { - let pageViewController = PageViewController(id: .show(event.show)) - navigationController.pushViewController(pageViewController, animated: true) - } -#endif + + #if os(tvOS) + navigateToShow(event.show) + #else + if let navigationController { + let pageViewController = PageViewController(id: .show(event.show)) + navigationController.pushViewController(pageViewController, animated: true) + } + #endif } } #if os(iOS) -extension SectionViewController: TabBarActionable { - func performActiveTabAction(animated: Bool) { - collectionView?.play_scrollToTop(animated: animated) + extension SectionViewController: TabBarActionable { + func performActiveTabAction(animated: Bool) { + collectionView?.play_scrollToTop(animated: animated) + } } -} #endif // MARK: Layout @@ -622,15 +615,15 @@ private extension SectionViewController { let configuration = UICollectionViewCompositionalLayoutConfiguration() configuration.contentInsetsReference = constant(iOS: .automatic, tvOS: .layoutMargins) configuration.interSectionSpacing = constant(iOS: 15, tvOS: 100) - + let titleHeaderSize = TitleHeaderViewSize.recommended(for: title, layoutWidth: layoutWidth, horizontalSizeClass: horizontalSizeClass) - configuration.boundarySupplementaryItems = [ NSCollectionLayoutBoundarySupplementaryItem(layoutSize: titleHeaderSize, elementKind: Header.titleHeader.rawValue, alignment: .topLeading) ] - + configuration.boundarySupplementaryItems = [NSCollectionLayoutBoundarySupplementaryItem(layoutSize: titleHeaderSize, elementKind: Header.titleHeader.rawValue, alignment: .topLeading)] + return configuration } - + private func layout() -> UICollectionViewLayout { - return UICollectionViewCompositionalLayout(sectionProvider: { [weak self] sectionIndex, layoutEnvironment in + UICollectionViewCompositionalLayout(sectionProvider: { [weak self] sectionIndex, layoutEnvironment in func sectionSupplementaryItems(for section: SectionViewModel.Section, configuration: SectionViewModel.Configuration, layoutEnvironment: NSCollectionLayoutEnvironment) -> [NSCollectionLayoutBoundarySupplementaryItem] { let headerSize = SectionHeaderView.size(section: section, configuration: configuration, @@ -638,79 +631,77 @@ private extension SectionViewController { horizontalSizeClass: layoutEnvironment.traitCollection.horizontalSizeClass) let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top) header.pinToVisibleBounds = configuration.viewModelProperties.pinHeadersToVisibleBounds - + let footerSize = SectionFooterView.size(section: section) let footer = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: footerSize, elementKind: UICollectionView.elementKindSectionFooter, alignment: .bottom) - + return [header, footer] } - + func layoutSection(for section: SectionViewModel.Section, configuration: SectionViewModel.Configuration, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection { let layoutWidth = layoutEnvironment.container.effectiveContentSize.width let horizontalSizeClass = layoutEnvironment.traitCollection.horizontalSizeClass let top = section.header.sectionTopInset - + switch configuration.viewModelProperties.layout { case .mediaList: -#if os(iOS) - let horizontalMargin = horizontalSizeClass == .compact ? Self.layoutHorizontalMargin : Self.layoutHorizontalMargin * 2 - return NSCollectionLayoutSection.horizontal(layoutWidth: layoutWidth, horizontalMargin: horizontalMargin, spacing: Self.itemSpacing, top: top) { _, _ in - return MediaCellSize.fullWidth(horizontalSizeClass: horizontalSizeClass) - } -#else - return NSCollectionLayoutSection.grid(layoutWidth: layoutWidth, horizontalMargin: Self.layoutHorizontalMargin, spacing: Self.itemSpacing, top: top) { layoutWidth, spacing in - return MediaCellSize.grid(layoutWidth: layoutWidth, spacing: spacing) - } -#endif + #if os(iOS) + let horizontalMargin = horizontalSizeClass == .compact ? Self.layoutHorizontalMargin : Self.layoutHorizontalMargin * 2 + return NSCollectionLayoutSection.horizontal(layoutWidth: layoutWidth, horizontalMargin: horizontalMargin, spacing: Self.itemSpacing, top: top) { _, _ in + MediaCellSize.fullWidth(horizontalSizeClass: horizontalSizeClass) + } + #else + return NSCollectionLayoutSection.grid(layoutWidth: layoutWidth, horizontalMargin: Self.layoutHorizontalMargin, spacing: Self.itemSpacing, top: top) { layoutWidth, spacing in + MediaCellSize.grid(layoutWidth: layoutWidth, spacing: spacing) + } + #endif case .mediaGrid: if horizontalSizeClass == .compact { return NSCollectionLayoutSection.horizontal(layoutWidth: layoutWidth, horizontalMargin: Self.layoutHorizontalMargin, spacing: Self.itemSpacing, top: top) { _, _ in - return MediaCellSize.fullWidth() + MediaCellSize.fullWidth() } - } - else { + } else { return NSCollectionLayoutSection.grid(layoutWidth: layoutWidth, horizontalMargin: Self.layoutHorizontalMargin, spacing: Self.itemSpacing, top: top) { layoutWidth, spacing in - return MediaCellSize.grid(layoutWidth: layoutWidth, spacing: spacing) + MediaCellSize.grid(layoutWidth: layoutWidth, spacing: spacing) } } case .liveMediaGrid: return NSCollectionLayoutSection.grid(layoutWidth: layoutWidth, horizontalMargin: Self.layoutHorizontalMargin, spacing: Self.itemSpacing, top: top) { layoutWidth, spacing in - return LiveMediaCellSize.grid(layoutWidth: layoutWidth, spacing: spacing) + LiveMediaCellSize.grid(layoutWidth: layoutWidth, spacing: spacing) } case .showGrid: return NSCollectionLayoutSection.grid(layoutWidth: layoutWidth, horizontalMargin: Self.layoutHorizontalMargin, spacing: Self.itemSpacing, top: top) { layoutWidth, spacing in - return ShowCellSize.grid(for: configuration.properties.imageVariant, layoutWidth: layoutWidth, spacing: spacing) + ShowCellSize.grid(for: configuration.properties.imageVariant, layoutWidth: layoutWidth, spacing: spacing) } case .topicGrid: return NSCollectionLayoutSection.grid(layoutWidth: layoutWidth, horizontalMargin: Self.layoutHorizontalMargin, spacing: Self.itemSpacing, top: top) { layoutWidth, spacing in - return TopicCellSize.grid(layoutWidth: layoutWidth, spacing: spacing) + TopicCellSize.grid(layoutWidth: layoutWidth, spacing: spacing) } -#if os(iOS) - case .downloadGrid: - if horizontalSizeClass == .compact { - return NSCollectionLayoutSection.horizontal(layoutWidth: layoutWidth, spacing: Self.itemSpacing, top: top) { _, _ in - return DownloadCellSize.fullWidth() + #if os(iOS) + case .downloadGrid: + if horizontalSizeClass == .compact { + return NSCollectionLayoutSection.horizontal(layoutWidth: layoutWidth, spacing: Self.itemSpacing, top: top) { _, _ in + DownloadCellSize.fullWidth() + } + } else { + return NSCollectionLayoutSection.grid(layoutWidth: layoutWidth, horizontalMargin: Self.layoutHorizontalMargin, spacing: Self.itemSpacing, top: top) { layoutWidth, spacing in + DownloadCellSize.grid(layoutWidth: layoutWidth, spacing: spacing) + } } - } - else { - return NSCollectionLayoutSection.grid(layoutWidth: layoutWidth, horizontalMargin: Self.layoutHorizontalMargin, spacing: Self.itemSpacing, top: top) { layoutWidth, spacing in - return DownloadCellSize.grid(layoutWidth: layoutWidth, spacing: spacing) + case .notificationList: + return NSCollectionLayoutSection.horizontal(layoutWidth: layoutWidth, horizontalMargin: Self.layoutHorizontalMargin, spacing: Self.itemSpacing, top: top) { _, _ in + NotificationCellSize.fullWidth() } - } - case .notificationList: - return NSCollectionLayoutSection.horizontal(layoutWidth: layoutWidth, horizontalMargin: Self.layoutHorizontalMargin, spacing: Self.itemSpacing, top: top) { _, _ in - return NotificationCellSize.fullWidth() - } -#endif + #endif } } - + guard let self else { return nil } - - let snapshot = self.dataSource.snapshot() + + let snapshot = dataSource.snapshot() let section = snapshot.sectionIdentifiers[sectionIndex] - let configuration = self.model.configuration - + let configuration = model.configuration + let layoutSection = layoutSection(for: section, configuration: configuration, layoutEnvironment: layoutEnvironment) layoutSection.boundarySupplementaryItems = sectionSupplementaryItems(for: section, configuration: configuration, layoutEnvironment: layoutEnvironment) layoutSection.supplementariesFollowContentInsets = false @@ -726,20 +717,19 @@ private extension SectionViewController { let item: SectionViewModel.Item let configuration: SectionViewModel.Configuration let isLastItem: Bool - + var body: some View { switch item { case let .media(media): switch configuration.wrappedValue { - case let .content(contentSection, _): + case let .content(contentSection, _, _): switch contentSection.type { case .predefined: switch contentSection.presentation.type { case .availableEpisodes: if configuration.viewModelProperties.layout == .mediaList { MediaCell(media: media, style: .dateAndSummary, layout: .horizontal) - } - else { + } else { MediaCell(media: media, style: .date) } default: @@ -753,8 +743,7 @@ private extension SectionViewController { case .availableEpisodes: if configuration.viewModelProperties.layout == .mediaList { MediaCell(media: media, style: .dateAndSummary, layout: .horizontal) - } - else { + } else { MediaCell(media: media, style: .date) } case .radioEpisodesForDay, .tvEpisodesForDay: @@ -766,7 +755,7 @@ private extension SectionViewController { case let .show(show): let imageVariant = configuration.properties.imageVariant switch configuration.wrappedValue { - case let .content(contentSection, _): + case let .content(contentSection, _, _): switch contentSection.type { case .predefined: switch contentSection.presentation.type { @@ -788,12 +777,12 @@ private extension SectionViewController { } case let .topic(topic): TopicCell(topic: topic) -#if os(iOS) - case let .download(download): - DownloadCell(download: download) - case let .notification(notification): - NotificationCell(notification: notification) -#endif + #if os(iOS) + case let .download(download): + DownloadCell(download: download) + case let .notification(notification): + NotificationCell(notification: notification) + #endif case .transparent: Color.clear default: @@ -809,7 +798,7 @@ private extension SectionViewController { struct SectionHeaderView: View { let section: SectionViewModel.Section let configuration: SectionViewModel.Configuration - + var body: some View { switch section.header { case let .title(title): @@ -825,20 +814,20 @@ private extension SectionViewController { Color.clear } } - + static func size(section: SectionViewModel.Section, configuration: SectionViewModel.Configuration, layoutWidth: CGFloat, horizontalSizeClass: UIUserInterfaceSizeClass) -> NSCollectionLayoutSize { switch section.header { case let .title(title): - return TransluscentHeaderViewSize.recommended(title: title, horizontalPadding: SectionViewController.layoutHorizontalMargin, layoutWidth: layoutWidth) + TransluscentHeaderViewSize.recommended(title: title, horizontalPadding: SectionViewController.layoutHorizontalMargin, layoutWidth: layoutWidth) case let .item(item): switch item { case let .show(show): - return SectionShowHeaderViewSize.recommended(for: configuration.wrappedValue, show: show, layoutWidth: layoutWidth, horizontalSizeClass: horizontalSizeClass) + SectionShowHeaderViewSize.recommended(for: configuration.wrappedValue, show: show, layoutWidth: layoutWidth, horizontalSizeClass: horizontalSizeClass) default: - return NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(LayoutHeaderHeightZero)) + NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(LayoutHeaderHeightZero)) } case .none: - return NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(LayoutHeaderHeightZero)) + NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(LayoutHeaderHeightZero)) } } } @@ -849,24 +838,24 @@ private extension SectionViewController { private extension SectionViewController { struct SectionFooterView: View { let section: SectionViewModel.Section - + var body: some View { switch section.footer { -#if os(iOS) - case .diskInfo: - DiskInfoFooterView() -#endif + #if os(iOS) + case .diskInfo: + DiskInfoFooterView() + #endif case .none: Color.clear } } - + static func size(section: SectionViewModel.Section) -> NSCollectionLayoutSize { switch section.footer { -#if os(iOS) - case .diskInfo: - return LayoutFullWidthCellSize(30) -#endif + #if os(iOS) + case .diskInfo: + return LayoutFullWidthCellSize(30) + #endif case .none: return NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(LayoutHeaderHeightZero)) } @@ -875,19 +864,19 @@ private extension SectionViewController { } extension SectionViewController: ShowHeaderViewAction { - func showMore(sender: Any?, event: ShowMoreEvent?) { + func showMore(sender _: Any?, event: ShowMoreEvent?) { guard let event else { return } - -#if os(iOS) - let sheetTextViewController = UIHostingController(rootView: SheetTextView(content: event.content)) - if #available(iOS 15.0, *) { - if let sheet = sheetTextViewController.sheetPresentationController { - sheet.detents = [.medium()] + + #if os(iOS) + let sheetTextViewController = UIHostingController(rootView: SheetTextView(content: event.content)) + if #available(iOS 15.0, *) { + if let sheet = sheetTextViewController.sheetPresentationController { + sheet.detents = [.medium()] + } } - } - present(sheetTextViewController, animated: true, completion: nil) -#else - navigateToText(event.content) -#endif + present(sheetTextViewController, animated: true, completion: nil) + #else + navigateToText(event.content) + #endif } } diff --git a/Application/Sources/Content/SectionViewModel.swift b/Application/Sources/Content/SectionViewModel.swift index 9bf3e841e..4ed651686 100644 --- a/Application/Sources/Content/SectionViewModel.swift +++ b/Application/Sources/Content/SectionViewModel.swift @@ -11,28 +11,28 @@ import SRGDataProviderCombine final class SectionViewModel: ObservableObject { let configuration: SectionViewModel.Configuration - + @Published private(set) var state: State = .loading - + private let trigger = Trigger() private var selectedItems = Set() - + var title: String? { - return configuration.properties.title + configuration.properties.title } - + var displaysTitle: Bool { - return configuration.properties.displaysTitle + configuration.properties.displaysTitle } - + var numberOfSelectedItems: Int { guard configuration.properties.supportsEdition else { return 0 } return selectedItems.count } - + init(section: Content.Section, filter: SectionFiltering?) { - self.configuration = Self.Configuration(section) - + configuration = Self.Configuration(section) + // Use property capture list (simpler code than if `self` is weakly captured). Only safe because we are // capturing constant values (see https://www.swiftbysundell.com/articles/swifts-closure-capturing-mechanics/) Publishers.Publish(onOutputFrom: reloadSignal()) { [configuration, trigger] in @@ -46,73 +46,73 @@ final class SectionViewModel: ObservableObject { .setFailureType(to: Error.self) ) .map { items, removedItems in - return items.filter { !removedItems.contains($0) } + items.filter { !removedItems.contains($0) } } .map { items in let rows = configuration.viewModelProperties.rows(from: removeDuplicates(in: items)) return State.loaded(rows: rows) } .catch { error in - return Just(State.failed(error: error)) + Just(State.failed(error: error)) } } .receive(on: DispatchQueue.main) .assign(to: &$state) } - + func loadMore() { trigger.activate(for: TriggerId.loadMore) } - + func reload(deep: Bool = false) { if deep || !state.hasContent { trigger.activate(for: TriggerId.reload) } } - + func select(_ item: Content.Item) { guard configuration.properties.supportsEdition else { return } selectedItems.insert(item) } - + func deselect(_ item: Content.Item) { guard configuration.properties.supportsEdition else { return } selectedItems.remove(item) } - + func clearSelection() { guard configuration.properties.supportsEdition else { return } selectedItems.removeAll() } - + func deleteSelection() { guard configuration.properties.supportsEdition else { return } - + let properties = configuration.properties properties.remove(Array(selectedItems)) selectedItems.removeAll() - + if let analyticsDeletionHiddenEvent = properties.analyticsDeletionHiddenEvent(source: .selection) { analyticsDeletionHiddenEvent.send() } } - + /// Can be used on all platforms to minimize preprocessor need, but never emits on platforms not supporting /// push notifications func resetApplicationBadgeIfNeeded() { -#if os(iOS) - guard let pushService = PushService.shared else { return } - - if configuration.properties.canResetApplicationBadge { - pushService.resetApplicationBadge() - } -#else - return -#endif + #if os(iOS) + guard let pushService = PushService.shared else { return } + + if configuration.properties.canResetApplicationBadge { + pushService.resetApplicationBadge() + } + #else + return + #endif } - + private func reloadSignal() -> AnyPublisher { - return Publishers.Merge3( + Publishers.Merge3( trigger.signal(activatedBy: TriggerId.reload), ApplicationSignal.wokenUp() .filter { [weak self] in @@ -131,167 +131,162 @@ final class SectionViewModel: ObservableObject { extension SectionViewModel { struct Configuration: Hashable { let wrappedValue: Content.Section - + init(_ wrappedValue: Content.Section) { self.wrappedValue = wrappedValue } - + var properties: SectionProperties { - return wrappedValue.properties + wrappedValue.properties } - + var viewModelProperties: SectionViewModelProperties { switch wrappedValue { - case let .content(section, _): - return ContentSectionProperties(contentSection: section) + case let .content(section, type, _): + ContentSectionProperties(contentSection: section, contentType: type) case let .configured(section): - return ConfiguredSectionProperties(configuredSection: section) + ConfiguredSectionProperties(configuredSection: section) } } } - + enum Header: Hashable { enum Size { case zero case small case large } - + case none case title(String) case item(Content.Item) - + var sectionTopInset: CGFloat { switch self { case .title: - return constant(iOS: 8, tvOS: 12) + constant(iOS: 8, tvOS: 12) default: - return 0 + 0 } } - + var size: Size { switch self { case .title: - return .small + .small case .item: - return .large + .large case .none: - return .zero + .zero } } } - + enum Footer: Hashable { case none -#if os(iOS) - case diskInfo -#endif + #if os(iOS) + case diskInfo + #endif } - + struct Section: Hashable, Indexable { let id: String let header: Header let footer: Footer - + var indexTitle: String { - return id.uppercased() + id.uppercased() } - + func hash(into hasher: inout Hasher) { hasher.combine(id) } } - + typealias Item = Content.Item - + // Non-empty rows only. The section view namely supports optional header pinning, and the layout could raise an // assertion if some collection section has no items, while its header height is smaller than the layout inter group // spacing. typealias Row = NonEmptyCollectionRow - + enum State { case loading case failed(error: Error) case loaded(rows: [Row]) - + var hasContent: Bool { if case let .loaded(rows: rows) = self { let filteredRows = rows.filter { $0.items.contains { $0 != .transparent } } return !filteredRows.isEmpty - } - else { + } else { return false } } - + var headerSize: Header.Size { if case let .loaded(rows: rows) = self, let firstSection = rows.first?.section { - return firstSection.header.size - } - else { - return .zero + firstSection.header.size + } else { + .zero } } - + var displaysEmptyContentView: Bool { - return headerSize != .large && !hasContent + headerSize != .large && !hasContent } } - + enum SectionLayout: Hashable { case liveMediaGrid case mediaList case mediaGrid case showGrid case topicGrid -#if os(iOS) - case downloadGrid - case notificationList -#endif + #if os(iOS) + case downloadGrid + case notificationList + #endif } - + enum TriggerId { case loadMore case reload } - + fileprivate static func consolidatedRows(with items: [Item], header: Header = .none, footer: Footer = .none) -> [Row] { let rowItems = (header.size == .large && items.isEmpty) ? [.transparent] : items if let row = Row(section: Section(id: "main", header: header, footer: footer), items: rowItems) { return [row] - } - else { + } else { return [] } } - + private static func alphabeticalRows(from groups: [(key: Character, value: [Item])]) -> [Row] { - return groups.compactMap { character, items in - return Row( + groups.compactMap { character, items in + Row( section: Section(id: String(character), header: .title(String(character).uppercased()), footer: .none), items: items ) } } - + /// Group items into alphabetical rows. If smart mode is enabled grouping is only performed when the result /// of the grouping is well-balanced. fileprivate static func alphabeticalRows(from items: [Item], smart: Bool) -> [Row] { let groups = Item.groupAlphabetically(items) guard groups.count > 1 else { return consolidatedRows(with: items) } - + if smart { // Group into different rows only if we have several groups whose row length median is larger than a // given threshold, so that we get a balanced result. if let medianCount = groups.map({ Double($0.value.count) }).median(), medianCount > 2 { return alphabeticalRows(from: groups) - } - else { + } else { return consolidatedRows(with: items) } - } - else { + } else { return alphabeticalRows(from: groups) } } @@ -303,14 +298,15 @@ protocol SectionViewModelProperties { var layout: SectionViewModel.SectionLayout { get } var pinHeadersToVisibleBounds: Bool { get } var largeTitleDisplayMode: UINavigationItem.LargeTitleDisplayMode { get } - + func rows(from items: [SectionViewModel.Item]) -> [SectionViewModel.Row] } private extension SectionViewModel { struct ContentSectionProperties: SectionViewModelProperties { let contentSection: SRGContentSection - + let contentType: ContentType + var layout: SectionViewModel.SectionLayout { switch contentSection.type { case .shows: @@ -326,11 +322,11 @@ private extension SectionViewModel { case .swimlane, .grid: return (contentSection.type == .shows) ? .showGrid : .mediaGrid case .availableEpisodes: -#if os(iOS) - return .mediaList -#else - return .mediaGrid -#endif + #if os(iOS) + return .mediaList + #else + return .mediaGrid + #endif default: return .mediaGrid } @@ -338,111 +334,110 @@ private extension SectionViewModel { return .mediaGrid } } - + var pinHeadersToVisibleBounds: Bool { -#if os(iOS) - switch contentSection.type { - case .predefined: - switch contentSection.presentation.type { - case .favoriteShows: - return true + #if os(iOS) + switch contentSection.type { + case .predefined: + switch contentSection.presentation.type { + case .favoriteShows: + return true + default: + return false + } default: + // Remark: `.shows` results cannot be arranged alphabetically because of pagination; no headers. return false } - default: - // Remark: `.shows` results cannot be arranged alphabetically because of pagination; no headers. + #else return false - } -#else - return false -#endif + #endif } - + var largeTitleDisplayMode: UINavigationItem.LargeTitleDisplayMode { switch contentSection.type { case .showAndMedias: - return .never + .never default: - return .always + .always } } - + func rows(from items: [SectionViewModel.Item]) -> [SectionViewModel.Row] { switch contentSection.type { case .showAndMedias: if let firstItem = items.first, case .show = firstItem { - return SectionViewModel.consolidatedRows(with: Array(items.suffix(from: 1)), header: .item(firstItem)) - } - else { - return SectionViewModel.consolidatedRows(with: items) + SectionViewModel.consolidatedRows(with: Array(items.suffix(from: 1)), header: .item(firstItem)) + } else { + SectionViewModel.consolidatedRows(with: items) } case .predefined: switch contentSection.presentation.type { case .favoriteShows: - return SectionViewModel.alphabeticalRows(from: items, smart: true) + SectionViewModel.alphabeticalRows(from: items, smart: true) default: - return SectionViewModel.consolidatedRows(with: items) + SectionViewModel.consolidatedRows(with: items) } default: // Remark: `.shows` results cannot be arranged alphabetically because of pagination. - return SectionViewModel.consolidatedRows(with: items) + SectionViewModel.consolidatedRows(with: items) } } } - + struct ConfiguredSectionProperties: SectionViewModelProperties { let configuredSection: ConfiguredSection - + var layout: SectionViewModel.SectionLayout { switch configuredSection { case .tvLive, .radioLive, .radioLiveSatellite: return .liveMediaGrid case .favoriteShows, .radioFavoriteShows, .radioAllShows, .tvAllShows: return .showGrid -#if os(iOS) - case .downloads: - return .downloadGrid - case .notifications: - return .notificationList -#endif + #if os(iOS) + case .downloads: + return .downloadGrid + case .notifications: + return .notificationList + #endif case .availableEpisodes: -#if os(iOS) - return .mediaList -#else - return .mediaGrid -#endif + #if os(iOS) + return .mediaList + #else + return .mediaGrid + #endif default: return .mediaGrid } } - + var pinHeadersToVisibleBounds: Bool { -#if os(iOS) - switch configuredSection { - case .favoriteShows, .radioFavoriteShows, .radioAllShows, .tvAllShows: - return true - default: + #if os(iOS) + switch configuredSection { + case .favoriteShows, .radioFavoriteShows, .radioAllShows, .tvAllShows: + return true + default: + return false + } + #else return false - } -#else - return false -#endif + #endif } - + var largeTitleDisplayMode: UINavigationItem.LargeTitleDisplayMode { - return .always + .always } - + func rows(from items: [SectionViewModel.Item]) -> [SectionViewModel.Row] { switch configuredSection { case .favoriteShows, .radioFavoriteShows: return SectionViewModel.alphabeticalRows(from: items, smart: true) case .radioAllShows, .tvAllShows: return SectionViewModel.alphabeticalRows(from: items, smart: false) -#if os(iOS) - case .downloads: - return SectionViewModel.consolidatedRows(with: items, footer: .diskInfo) -#endif + #if os(iOS) + case .downloads: + return SectionViewModel.consolidatedRows(with: items, footer: .diskInfo) + #endif default: return SectionViewModel.consolidatedRows(with: items) } diff --git a/Application/Sources/Content/ShowHeaderView.swift b/Application/Sources/Content/ShowHeaderView.swift index 11ad469a3..2050aaa03 100644 --- a/Application/Sources/Content/ShowHeaderView.swift +++ b/Application/Sources/Content/ShowHeaderView.swift @@ -15,12 +15,12 @@ import SwiftUI class ShowMoreEvent: UIEvent { let content: String - + init(content: String) { self.content = content super.init() } - + override init() { fatalError("init() is not available") } @@ -32,24 +32,24 @@ class ShowMoreEvent: UIEvent { struct ShowHeaderView: View, PrimaryColorSettable { @Binding private(set) var show: SRGShow? let horizontalPadding: CGFloat - - internal var primaryColor: Color = .srgGrayD2 - + + var primaryColor: Color = .srgGrayD2 + static let imageAspectRatio: CGFloat = 16 / 9 - + static func isVerticalLayout(horizontalSizeClass: UIUserInterfaceSizeClass, isLandscape: Bool) -> Bool { - return horizontalSizeClass == .compact || !isLandscape + horizontalSizeClass == .compact || !isLandscape } - + @StateObject private var model = ShowHeaderViewModel() - + fileprivate static let verticalSpacing: CGFloat = 24 - + init(_ show: SRGShow?, horizontalPadding: CGFloat) { _show = .constant(show) self.horizontalPadding = horizontalPadding } - + var body: some View { MainView(model: model, horizontalPadding: horizontalPadding) .primaryColor(primaryColor) @@ -60,27 +60,27 @@ struct ShowHeaderView: View, PrimaryColorSettable { model.show = newValue } } - + /// Behavior: h-hug, v-hug. fileprivate struct MainView: View, PrimaryColorSettable { @ObservedObject var model: ShowHeaderViewModel let horizontalPadding: CGFloat @Environment(\.uiHorizontalSizeClass) private var horizontalSizeClass - + @State private var isLandscape: Bool - - internal var primaryColor: Color = .srgGrayD2 - + + var primaryColor: Color = .srgGrayD2 + init(model: ShowHeaderViewModel, horizontalPadding: CGFloat) { self.model = model self.horizontalPadding = horizontalPadding - self.isLandscape = (UIApplication.shared.mainWindow?.isLandscape ?? false) + isLandscape = (UIApplication.shared.mainWindow?.isLandscape ?? false) } - + private var padding: CGFloat { - return horizontalSizeClass == .compact ? horizontalPadding : horizontalPadding * 2 + horizontalSizeClass == .compact ? horizontalPadding : horizontalPadding * 2 } - + var body: some View { Group { if isVerticalLayout(horizontalSizeClass: horizontalSizeClass, isLandscape: isLandscape) { @@ -94,8 +94,7 @@ struct ShowHeaderView: View, PrimaryColorSettable { .padding(.horizontal, padding) } .padding(.bottom, 24) - } - else { + } else { HStack(spacing: constant(iOS: padding, tvOS: 50)) { DescriptionView(model: model, compactLayout: false) .primaryColor(primaryColor) @@ -113,22 +112,22 @@ struct ShowHeaderView: View, PrimaryColorSettable { } } } - + /// Behavior: h-hug, v-hug private struct DescriptionView: View, PrimaryColorSettable { @ObservedObject var model: ShowHeaderViewModel let compactLayout: Bool - - internal var primaryColor: Color = .srgGrayD2 - + + var primaryColor: Color = .srgGrayD2 + var body: some View { VStack(alignment: .leading, spacing: ShowHeaderView.verticalSpacing) { Text(model.title ?? "") .srgFont(.H2) .lineLimit(2) - // Fix sizing issue, see https://swiftui-lab.com/bug-linelimit-ignored/. The size is correct - // when calculated with a `UIHostingController`, but without this the text does not occupy - // all lines it could. + // Fix sizing issue, see https://swiftui-lab.com/bug-linelimit-ignored/. The size is correct + // when calculated with a `UIHostingController`, but without this the text does not occupy + // all lines it could. .fixedSize(horizontal: false, vertical: true) .multilineTextAlignment(.leading) .foregroundColor(primaryColor) @@ -138,7 +137,7 @@ struct ShowHeaderView: View, PrimaryColorSettable { if let summary = model.show?.play_summary { SummaryView(summary) .primaryColor(primaryColor) - // See above + // See above .fixedSize(horizontal: false, vertical: true) } ActionsView(model: model, compactLayout: compactLayout) @@ -148,19 +147,19 @@ struct ShowHeaderView: View, PrimaryColorSettable { .frame(maxWidth: .infinity, alignment: .leading) .focusable() } - + /// Behavior: h-exp, v-hug private struct SummaryView: View, PrimaryColorSettable { let content: String - - internal var primaryColor: Color = .srgGrayD2 - + + var primaryColor: Color = .srgGrayD2 + @FirstResponder private var firstResponder - + init(_ content: String) { self.content = content } - + var body: some View { TruncatableTextView(content: content, lineLimit: 3) { firstResponder.sendAction(#selector(ShowHeaderViewAction.showMore(sender:event:)), for: ShowMoreEvent(content: content)) @@ -169,13 +168,13 @@ struct ShowHeaderView: View, PrimaryColorSettable { .responderChain(from: firstResponder) } } - + private struct ActionsView: View, PrimaryColorSettable { @ObservedObject var model: ShowHeaderViewModel let compactLayout: Bool - - internal var primaryColor: Color = .srgGrayD2 - + + var primaryColor: Color = .srgGrayD2 + var body: some View { HStack(spacing: 8) { if compactLayout { @@ -183,48 +182,46 @@ struct ShowHeaderView: View, PrimaryColorSettable { label: model.favoriteLabel, accessibilityLabel: model.favoriteAccessibilityLabel, action: favoriteAction) - .primaryColor(primaryColor) - .alert(isPresented: $model.isFavoriteRemovalAlertDisplayed, content: favoriteRemovalAlert) -#if os(iOS) - if model.isSubscriptionPossible { - ExpandingButton(icon: model.subscriptionIcon, - label: model.subscriptionLabel, - accessibilityLabel: model.subscriptionAccessibilityLabel, - action: subscriptionAction) .primaryColor(primaryColor) - } -#endif - } - else { + .alert(isPresented: $model.isFavoriteRemovalAlertDisplayed, content: favoriteRemovalAlert) + #if os(iOS) + if model.isSubscriptionPossible { + ExpandingButton(icon: model.subscriptionIcon, + label: model.subscriptionLabel, + accessibilityLabel: model.subscriptionAccessibilityLabel, + action: subscriptionAction) + .primaryColor(primaryColor) + } + #endif + } else { SimpleButton(icon: model.favoriteIcon, label: model.favoriteLabel, labelMinimumScaleFactor: 1, accessibilityLabel: model.favoriteAccessibilityLabel, action: favoriteAction) - .primaryColor(primaryColor) -#if os(iOS) - if model.isSubscriptionPossible { - SimpleButton(icon: model.subscriptionIcon, - label: model.subscriptionLabel, - accessibilityLabel: model.subscriptionAccessibilityLabel, - action: subscriptionAction) .primaryColor(primaryColor) - } -#endif + #if os(iOS) + if model.isSubscriptionPossible { + SimpleButton(icon: model.subscriptionIcon, + label: model.subscriptionLabel, + accessibilityLabel: model.subscriptionAccessibilityLabel, + action: subscriptionAction) + .primaryColor(primaryColor) + } + #endif } } .alert(isPresented: $model.isFavoriteRemovalAlertDisplayed, content: favoriteRemovalAlert) } - + private func favoriteAction() { if model.shouldDisplayFavoriteRemovalAlert { model.isFavoriteRemovalAlertDisplayed = true - } - else { + } else { model.toggleFavorite() } } - + private func favoriteRemovalAlert() -> Alert { let primaryButton = Alert.Button.cancel(Text(NSLocalizedString("Cancel", comment: "Title of a cancel button"))) {} let secondaryButton = Alert.Button.destructive(Text(NSLocalizedString("Delete", comment: "Title of a delete button"))) { @@ -235,12 +232,12 @@ struct ShowHeaderView: View, PrimaryColorSettable { primaryButton: primaryButton, secondaryButton: secondaryButton) } - -#if os(iOS) - private func subscriptionAction() { - model.toggleSubscription() - } -#endif + + #if os(iOS) + private func subscriptionAction() { + model.toggleSubscription() + } + #endif } } } @@ -255,8 +252,7 @@ enum ShowHeaderViewSize { model.show = show let size = ShowHeaderView.MainView(model: model, horizontalPadding: horizontalPadding).adaptiveSizeThatFits(in: fittingSize, for: horizontalSizeClass) return NSCollectionLayoutSize(widthDimension: .absolute(size.width), heightDimension: .absolute(size.height)) - } - else { + } else { return NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(LayoutHeaderHeightZero)) } } @@ -268,36 +264,36 @@ struct ShowHeaderView_Previews: PreviewProvider { model.show = Mock.show() return model }() - + private static let model2: ShowHeaderViewModel = { let model = ShowHeaderViewModel() model.show = Mock.show(.overflow) return model }() - + static var previews: some View { -#if os(tvOS) - Group { - ShowHeaderView.MainView(model: model1, horizontalPadding: 0) - ShowHeaderView.MainView(model: model2, horizontalPadding: 0) - } - .previewLayout(.sizeThatFits) -#else - Group { - ShowHeaderView.MainView(model: model1, horizontalPadding: 16) - ShowHeaderView.MainView(model: model2, horizontalPadding: 16) - } - .previewLayout(.sizeThatFits) - .frame(width: 1000) - .environment(\.horizontalSizeClass, .regular) - - Group { - ShowHeaderView.MainView(model: model1, horizontalPadding: 16) - ShowHeaderView.MainView(model: model2, horizontalPadding: 16) - } - .frame(width: 375) - .previewLayout(.sizeThatFits) - .environment(\.horizontalSizeClass, .compact) -#endif + #if os(tvOS) + Group { + ShowHeaderView.MainView(model: model1, horizontalPadding: 0) + ShowHeaderView.MainView(model: model2, horizontalPadding: 0) + } + .previewLayout(.sizeThatFits) + #else + Group { + ShowHeaderView.MainView(model: model1, horizontalPadding: 16) + ShowHeaderView.MainView(model: model2, horizontalPadding: 16) + } + .previewLayout(.sizeThatFits) + .frame(width: 1000) + .environment(\.horizontalSizeClass, .regular) + + Group { + ShowHeaderView.MainView(model: model1, horizontalPadding: 16) + ShowHeaderView.MainView(model: model2, horizontalPadding: 16) + } + .frame(width: 375) + .previewLayout(.sizeThatFits) + .environment(\.horizontalSizeClass, .compact) + #endif } } diff --git a/Application/Sources/Content/ShowHeaderViewModel.swift b/Application/Sources/Content/ShowHeaderViewModel.swift index 79b5191c2..07328249e 100644 --- a/Application/Sources/Content/ShowHeaderViewModel.swift +++ b/Application/Sources/Content/ShowHeaderViewModel.swift @@ -12,14 +12,14 @@ import SRGIdentity final class ShowHeaderViewModel: ObservableObject { @Published var show: SRGShow? - + @Published private(set) var isFavorite = false @Published private(set) var subscriptionStatus: UserDataPublishers.SubscriptionStatus = .unavailable - + @Published var isFavoriteRemovalAlertDisplayed = false - + private var wouldLikeToSubscribe = false - + init() { // Drop initial values; relevant values are first assigned when the view appears $show @@ -33,118 +33,115 @@ final class ShowHeaderViewModel: ObservableObject { .switchToLatest() .receive(on: DispatchQueue.main) .assign(to: &$isFavorite) -#if os(iOS) - // Drop initial values; relevant values are first assigned when the view appears - $show - .dropFirst() - .map { show in - guard let show else { - return Just(UserDataPublishers.SubscriptionStatus.unavailable).eraseToAnyPublisher() + #if os(iOS) + // Drop initial values; relevant values are first assigned when the view appears + $show + .dropFirst() + .map { show in + guard let show else { + return Just(UserDataPublishers.SubscriptionStatus.unavailable).eraseToAnyPublisher() + } + return UserDataPublishers.subscriptionStatusPublisher(for: show) } - return UserDataPublishers.subscriptionStatusPublisher(for: show) - } - .switchToLatest() - .receive(on: DispatchQueue.main) - .map { subscriptionStatus in - if self.wouldLikeToSubscribe { - if let pushService = PushService.shared, pushService.isEnabled { - if subscriptionStatus != .subscribed { - self.toggleSubscription() - } - else if let show = self.show { - Banner.showSubscription(true, forItemWithName: show.title) + .switchToLatest() + .receive(on: DispatchQueue.main) + .map { subscriptionStatus in + if self.wouldLikeToSubscribe { + if let pushService = PushService.shared, pushService.isEnabled { + if subscriptionStatus != .subscribed { + self.toggleSubscription() + } else if let show = self.show { + Banner.showSubscription(true, forItemWithName: show.title) + } + self.wouldLikeToSubscribe = false } - self.wouldLikeToSubscribe = false } + return subscriptionStatus } - return subscriptionStatus - } - .assign(to: &$subscriptionStatus) -#endif + .assign(to: &$subscriptionStatus) + #endif } - + var title: String? { - return show?.title + show?.title } - + var summary: String? { - return show?.play_summary + show?.play_summary } - + var broadcastInformation: String? { - return show?.broadcastInformation?.message + show?.broadcastInformation?.message } - + var favoriteIcon: ImageResource { - return isFavorite ? .favoriteFull : .favorite + isFavorite ? .favoriteFull : .favorite } - + var favoriteLabel: String { if isFavorite { - return NSLocalizedString("Favorites", comment: "Label displayed in the show view when a show has been favorited") - } - else { - return NSLocalizedString("Add to favorites", comment: "Label displayed in the show view when a show can be favorited") + NSLocalizedString("Favorites", comment: "Label displayed in the show view when a show has been favorited") + } else { + NSLocalizedString("Add to favorites", comment: "Label displayed in the show view when a show can be favorited") } } - + var shouldDisplayFavoriteRemovalAlert: Bool { guard let loggedIn = SRGIdentityService.current?.isLoggedIn, loggedIn, let show else { return false } return FavoritesIsSubscribedToShow(show) } - -#if os(iOS) - var isSubscriptionPossible: Bool { - return PushService.shared != nil - } - - var subscriptionIcon: ImageResource { - switch subscriptionStatus { - case .unavailable, .unsubscribed: - return .subscription - case .subscribed: - return .subscriptionFull + + #if os(iOS) + var isSubscriptionPossible: Bool { + PushService.shared != nil } - } - - var subscriptionLabel: String { - switch subscriptionStatus { - case .unavailable, .unsubscribed: - return NSLocalizedString("Notify me", comment: "Subscription label to be notified in the show view") - case .subscribed: - return NSLocalizedString("Notified", comment: "Subscription label when notification enabled in the show view") + + var subscriptionIcon: ImageResource { + switch subscriptionStatus { + case .unavailable, .unsubscribed: + .subscription + case .subscribed: + .subscriptionFull + } } - } -#endif - + + var subscriptionLabel: String { + switch subscriptionStatus { + case .unavailable, .unsubscribed: + NSLocalizedString("Notify me", comment: "Subscription label to be notified in the show view") + case .subscribed: + NSLocalizedString("Notified", comment: "Subscription label when notification enabled in the show view") + } + } + #endif + func toggleFavorite() { guard let show else { return } FavoritesToggleShow(show) - + let action = isFavorite ? .remove : .add as AnalyticsListAction AnalyticsEvent.favorite(action: action, source: .button, urn: show.urn).send() - -#if os(iOS) - Banner.showFavorite(!isFavorite, forItemWithName: show.title) -#endif + + #if os(iOS) + Banner.showFavorite(!isFavorite, forItemWithName: show.title) + #endif } - -#if os(iOS) - func toggleSubscription() { - guard let show else { return } - - if FavoritesToggleSubscriptionForShow(show) { - let isSubscribed = (subscriptionStatus == .subscribed) - let action = isSubscribed ? .remove : .add as AnalyticsListAction - AnalyticsEvent.subscription(action: action, source: .button, urn: show.urn).send() - - Banner.showSubscription(!isSubscribed, forItemWithName: show.title) - } - else if let pushService = PushService.shared, !pushService.isEnabled { - wouldLikeToSubscribe = true + + #if os(iOS) + func toggleSubscription() { + guard let show else { return } + + if FavoritesToggleSubscriptionForShow(show) { + let isSubscribed = (subscriptionStatus == .subscribed) + let action = isSubscribed ? .remove : .add as AnalyticsListAction + AnalyticsEvent.subscription(action: action, source: .button, urn: show.urn).send() + + Banner.showSubscription(!isSubscribed, forItemWithName: show.title) + } else if let pushService = PushService.shared, !pushService.isEnabled { + wouldLikeToSubscribe = true + } } - } -#endif + #endif } // MARK: Accessibility @@ -152,21 +149,20 @@ final class ShowHeaderViewModel: ObservableObject { extension ShowHeaderViewModel { var favoriteAccessibilityLabel: String { if isFavorite { - return PlaySRGAccessibilityLocalizedString("Delete from favorites", comment: "Favorite label in the show view when a show has been favorited") - } - else { - return PlaySRGAccessibilityLocalizedString("Add to favorites", comment: "Favorite label in the show view when a show can be favorited") + PlaySRGAccessibilityLocalizedString("Delete from favorites", comment: "Favorite label in the show view when a show has been favorited") + } else { + PlaySRGAccessibilityLocalizedString("Add to favorites", comment: "Favorite label in the show view when a show can be favorited") } } - -#if os(iOS) - var subscriptionAccessibilityLabel: String { - switch subscriptionStatus { - case .unavailable, .unsubscribed: - return PlaySRGAccessibilityLocalizedString("Enable notifications for show", comment: "Show subscription label") - case .subscribed: - return PlaySRGAccessibilityLocalizedString("Disable notifications for show", comment: "Show unsubscription label") + + #if os(iOS) + var subscriptionAccessibilityLabel: String { + switch subscriptionStatus { + case .unavailable, .unsubscribed: + PlaySRGAccessibilityLocalizedString("Enable notifications for show", comment: "Show subscription label") + case .subscribed: + PlaySRGAccessibilityLocalizedString("Disable notifications for show", comment: "Show unsubscription label") + } } - } -#endif + #endif } diff --git a/Application/Sources/DeepLinking/DeepLinkAction.m b/Application/Sources/DeepLinking/DeepLinkAction.m index 79e801a73..d38f1e51d 100644 --- a/Application/Sources/DeepLinking/DeepLinkAction.m +++ b/Application/Sources/DeepLinking/DeepLinkAction.m @@ -62,12 +62,12 @@ @implementation DeepLinkAction + (instancetype)unsupportedActionWithSource:(AnalyticsOpenUrlSource)source { AnalyticsEventObjC *hiddenEvent = [AnalyticsEventObjC openUrlWithAction:AnalyticsOpenUrlActionOpenPlayApp - source:source - urn:nil]; + source:source + urn:nil]; return [[self alloc] initWithType:DeepLinkTypeUnsupported identifier:@"" - analyticsEvent:hiddenEvent + analyticsEvent:hiddenEvent queryItems:nil]; } @@ -92,12 +92,12 @@ + (instancetype)actionFromURL:(NSURL *)URL source:(AnalyticsOpenUrlSource)source } AnalyticsEventObjC *hiddenEvent = [AnalyticsEventObjC openUrlWithAction:AnalyticsOpenUrlActionPlayMedia - source:source - urn:mediaURN]; + source:source + urn:mediaURN]; return [[self alloc] initWithType:type identifier:mediaURN - analyticsEvent:hiddenEvent + analyticsEvent:hiddenEvent queryItems:URLComponents.queryItems]; } else if ([type isEqualToString:DeepLinkTypeShow]) { @@ -107,12 +107,12 @@ + (instancetype)actionFromURL:(NSURL *)URL source:(AnalyticsOpenUrlSource)source } AnalyticsEventObjC *hiddenEvent = [AnalyticsEventObjC openUrlWithAction:AnalyticsOpenUrlActionDisplayShow - source:source - urn:showURN]; + source:source + urn:showURN]; return [[self alloc] initWithType:type identifier:showURN - analyticsEvent:hiddenEvent + analyticsEvent:hiddenEvent queryItems:URLComponents.queryItems]; } else if ([type isEqualToString:DeepLinkTypeTopic]) { @@ -122,12 +122,12 @@ + (instancetype)actionFromURL:(NSURL *)URL source:(AnalyticsOpenUrlSource)source } AnalyticsEventObjC *hiddenEvent = [AnalyticsEventObjC openUrlWithAction:AnalyticsOpenUrlActionDisplayPage - source:source - urn:topicURN]; + source:source + urn:topicURN]; return [[self alloc] initWithType:type identifier:topicURN - analyticsEvent:hiddenEvent + analyticsEvent:hiddenEvent queryItems:URLComponents.queryItems]; } else if ([type isEqualToString:DeepLinkTypePage] || [type isEqualToString:DeepLinkTypeMicroPage]) { @@ -147,12 +147,12 @@ + (instancetype)actionFromURL:(NSURL *)URL source:(AnalyticsOpenUrlSource)source } else if ([@[ DeepLinkTypeHome, DeepLinkTypeAZ, DeepLinkTypeByDate, DeepLinkTypeSearch, DeepLinkTypeLivestreams ] containsObject:type]) { AnalyticsEventObjC *hiddenEvent = [AnalyticsEventObjC openUrlWithAction:AnalyticsOpenUrlActionDisplayPage - source:source - urn:type]; + source:source + urn:type]; return [[self alloc] initWithType:type identifier:type - analyticsEvent:hiddenEvent + analyticsEvent:hiddenEvent queryItems:URLComponents.queryItems]; } else if ([type isEqualToString:DeepLinkTypeSection]) { @@ -162,12 +162,12 @@ + (instancetype)actionFromURL:(NSURL *)URL source:(AnalyticsOpenUrlSource)source } AnalyticsEventObjC *hiddenEvent = [AnalyticsEventObjC openUrlWithAction:AnalyticsOpenUrlActionDisplayPage - source:source - urn:sectionUid]; + source:source + urn:sectionUid]; return [[self alloc] initWithType:type identifier:sectionUid - analyticsEvent:hiddenEvent + analyticsEvent:hiddenEvent queryItems:URLComponents.queryItems]; } else if ([type isEqualToString:DeepLinkTypeLink]) { @@ -177,12 +177,12 @@ + (instancetype)actionFromURL:(NSURL *)URL source:(AnalyticsOpenUrlSource)source } AnalyticsEventObjC *hiddenEvent = [AnalyticsEventObjC openUrlWithAction:AnalyticsOpenUrlActionDisplayUrl - source:source - urn:URLString]; + source:source + urn:URLString]; return [[self alloc] initWithType:type identifier:URLString - analyticsEvent:hiddenEvent + analyticsEvent:hiddenEvent queryItems:URLComponents.queryItems]; } #if TARGET_OS_IOS @@ -211,7 +211,7 @@ + (NSString *)valueForParameterWithName:(NSString *)name inQueryItems:(NSArray *)queryItems { diff --git a/Application/Sources/Helpers/Accessibility.swift b/Application/Sources/Helpers/Accessibility.swift index a3e6bf408..58a61e4f4 100644 --- a/Application/Sources/Helpers/Accessibility.swift +++ b/Application/Sources/Helpers/Accessibility.swift @@ -9,27 +9,27 @@ import SwiftUI @propertyWrapper struct Accessibility: DynamicProperty { @ObservedObject private var settings = AccessibilitySettings.shared - + private let keyPath: KeyPath - + public init(_ keyPath: KeyPath) { self.keyPath = keyPath } - + public var wrappedValue: T { - return settings[keyPath: keyPath] + settings[keyPath: keyPath] } } final class AccessibilitySettings: ObservableObject { static let shared = AccessibilitySettings() - + // Remark: Some of these are readily accessible as environment values from SwiftUI: // - `accessibilityDifferentiateWithoutColor`, // - `accessibilityReduceTransparency` // - `accessibilityReduceMotion` // - `accessibilityInvertColors` - + @Published var isVoiceOverRunning = UIAccessibility.isVoiceOverRunning @Published var isMonoAudioEnabled = UIAccessibility.isMonoAudioEnabled @Published var isClosedCaptioningEnabled = UIAccessibility.isClosedCaptioningEnabled @@ -50,7 +50,7 @@ final class AccessibilitySettings: ObservableObject { @Published var isAssistiveTouchRunning = UIAccessibility.isAssistiveTouchRunning @Published var shouldDifferentiateWithoutColor = UIAccessibility.shouldDifferentiateWithoutColor @Published var isOnOffSwitchLabelsEnabled = UIAccessibility.isOnOffSwitchLabelsEnabled - + private init() { NotificationCenter.default.weakPublisher(for: UIAccessibility.voiceOverStatusDidChangeNotification) .map { _ in UIAccessibility.isVoiceOverRunning } diff --git a/Application/Sources/Helpers/AnalyticsClickEvent.swift b/Application/Sources/Helpers/AnalyticsClickEvent.swift index c2aacd3f9..ce73e6497 100644 --- a/Application/Sources/Helpers/AnalyticsClickEvent.swift +++ b/Application/Sources/Helpers/AnalyticsClickEvent.swift @@ -13,11 +13,11 @@ import SRGDataProviderModel struct AnalyticsClickEvent { let name: String let labels: SRGAnalyticsEventLabels - + private enum PageId: String { case tvGuide } - + /** * Each struct created have expected values. * Use this method to send the event when needed. @@ -25,9 +25,9 @@ struct AnalyticsClickEvent { func send() { SRGAnalyticsTracker.shared.trackEvent(withName: name, labels: labels) } - + static func tvGuideOpenInfoBox(program: SRGProgram, programGuideLayout: ProgramGuideLayout) -> Self { - return Self( + Self( name: "TvGuideOpenInfoBox", value1: PageId.tvGuide.rawValue, value2: programGuideLayout == .grid ? "Grid" : "List", @@ -35,14 +35,14 @@ struct AnalyticsClickEvent { value4: program.mediaURN ) } - + enum TvGuidePlaySource: String { case infoBox = "InfoBox" case grid = "Grid" } - + static func tvGuidePlayLivestream(program: SRGProgram, channel: SRGChannel, source: TvGuidePlaySource = .infoBox) -> Self { - return Self( + Self( name: "TvGuidePlayLivestream", value1: PageId.tvGuide.rawValue, value2: channel.title, @@ -50,9 +50,9 @@ struct AnalyticsClickEvent { value4: program.mediaURN ) } - + static func tvGuidePlayMedia(media: SRGMedia, programIsLive: Bool, channel: SRGChannel, source: TvGuidePlaySource = .infoBox) -> Self { - return Self( + Self( name: "TvGuidePlayMedia", value1: PageId.tvGuide.rawValue, value2: media.urn, @@ -60,54 +60,54 @@ struct AnalyticsClickEvent { value4: programIsLive ? channel.title : nil ) } - + static func tvGuideNow() -> Self { - return Self( + Self( name: "DateSelectionNowClick", value1: PageId.tvGuide.rawValue ) } - + static func tvGuideTonight() -> Self { - return Self( + Self( name: "DateSelectionTonightClick", value1: PageId.tvGuide.rawValue ) } - + static func tvGuidePreviousDay() -> Self { - return Self( + Self( name: "DateSelectionPreviousDayClick", value1: PageId.tvGuide.rawValue ) } - + static func tvGuideNextDay() -> Self { - return Self( + Self( name: "DateSelectionNextDayClick", value1: PageId.tvGuide.rawValue ) } - + static func tvGuideCalendar(to selectedDate: Date) -> Self { - return Self( + Self( name: "DateSelectionCalendarClick", value1: DateFormatter.play_iso8601CalendarDate.string(from: selectedDate), value2: PageId.tvGuide.rawValue ) } - + static func tvGuideChangeLayout(to programGuideLayout: ProgramGuideLayout) -> Self { - return Self( + Self( name: "TvGuideSwitchLayout", value1: PageId.tvGuide.rawValue, value2: programGuideLayout == .grid ? "Grid" : "List" ) } - + private init(name: String, value1: String? = nil, value2: String? = nil, value3: String? = nil, value4: String? = nil, value5: String? = nil) { self.name = name - + let labels = SRGAnalyticsEventLabels() labels.source = "2797" labels.type = "ClickEvent" diff --git a/Application/Sources/Helpers/AnalyticsEvent.swift b/Application/Sources/Helpers/AnalyticsEvent.swift index 05dfdec32..1240f1800 100644 --- a/Application/Sources/Helpers/AnalyticsEvent.swift +++ b/Application/Sources/Helpers/AnalyticsEvent.swift @@ -13,7 +13,7 @@ import SRGDataProviderModel struct AnalyticsEvent { private let name: String private let labels: SRGAnalyticsEventLabels - + /** * Each struct created have expected values. * Use this method to send the event when needed. @@ -21,97 +21,97 @@ struct AnalyticsEvent { func send() { SRGAnalyticsTracker.shared.trackEvent(withName: name, labels: labels) } - + static func calendarEventAdd(channel: SRGChannel) -> Self { - return Self( + Self( name: "calendar_add", source: "button", value: channel.urn, value1: channel.title ) } - + static func continuousPlayback(action: AnalyticsContiniousPlaybackAction, mediaUrn: String) -> Self { - return Self( + Self( name: "continuous_playback", source: action.source, type: action.type, value: mediaUrn ) } - + static func download(action: AnalyticsListAction, source: AnalyticsListSource, urn: String?) -> Self { - return Self( + Self( name: action.downloadName, source: source.value, value: urn ) } - + static func favorite(action: AnalyticsListAction, source: AnalyticsListSource, urn: String?) -> Self { - return Self( + Self( name: action.favoriteName, source: source.value, value: urn ) } - + static func googleGast(urn: String) -> Self { - return Self( + Self( name: "google_cast", value: urn ) } - + static func historyRemove(source: AnalyticsListSource, urn: String?) -> Self { - return Self( + Self( name: "history_remove", source: source.value, value: urn ) } - + static func identity(action: AnalyticsIdentityAction) -> Self { - return Self( + Self( name: "identity", labels: action.labels ) } - + static func notification(action: AnalyticsNotificationAction, from: AnalyticsNotificationFrom, uid: String, overrideSource: String? = nil, overrideType: String? = nil) -> Self { - return Self( + Self( name: from.name, source: overrideSource ?? from.source, type: overrideType ?? action.type, value: uid ) } - + static func openUrl(action: AnalyticsOpenUrlAction, source: AnalyticsOpenUrlSource, urn: String?) -> Self { - return Self( + Self( name: "open_url", source: source.value, type: action.type, value: urn ) } - + static func openHelp(action: AnalyticsOpenHelpAction) -> Self { - return Self( + Self( name: action.name, source: "button" ) } - + static func pictureInPicture(urn: String?) -> Self { - return Self( + Self( name: "picture_in_picture", value: urn ) } - + static func sharing(action: AnalyticsSharingAction, uid: String, mediaContentType: AnalyticsSharingMediaContentType, source: AnalyticsSharingSource, type: String?) -> Self { - return Self( + Self( name: action.name, source: source.value, type: type, @@ -119,42 +119,42 @@ struct AnalyticsEvent { value1: mediaContentType.value ) } - + static func shortcutItem(action: AnalyticsShortcutItemAction) -> Self { - return Self( + Self( name: "quick_actions", type: action.type ) } - + static func subscription(action: AnalyticsListAction, source: AnalyticsListSource, urn: String?) -> Self { - return Self( + Self( name: action.subscriptionName, source: source.value, value: urn ) } - + static func userActivity(action: AnalyticsUserActivityAction, urn: String) -> Self { - return Self( + Self( name: "user_activity_ios", source: "handoff", type: action.type, value: urn ) } - + static func watchLater(action: AnalyticsListAction, source: AnalyticsListSource, urn: String?) -> Self { - return Self( + Self( name: action.watchLaterName, source: source.value, value: urn ) } - + private init(name: String, source: String? = nil, type: String? = nil, value: String? = nil, value1: String? = nil, value2: String? = nil, value3: String? = nil, value4: String? = nil, value5: String? = nil) { self.name = name - + let labels = SRGAnalyticsEventLabels() labels.source = source labels.type = type @@ -166,7 +166,7 @@ struct AnalyticsEvent { labels.extraValue5 = value5 self.labels = labels } - + private init(name: String, labels: SRGAnalyticsEventLabels) { self.name = name self.labels = labels @@ -178,63 +178,63 @@ struct AnalyticsEvent { */ @objc class AnalyticsEventObjC: NSObject { private let event: AnalyticsEvent - + /** * Each object created have expected values. * Use this method to send the event when needed. */ @objc func send() { - self.event.send() + event.send() } - + @objc class func continuousPlayback(action: AnalyticsContiniousPlaybackAction, mediaUrn: String) -> AnalyticsEventObjC { - return Self(event: AnalyticsEvent.continuousPlayback(action: action, mediaUrn: mediaUrn)) + Self(event: AnalyticsEvent.continuousPlayback(action: action, mediaUrn: mediaUrn)) } - + @objc class func download(action: AnalyticsListAction, source: AnalyticsListSource, urn: String?) -> AnalyticsEventObjC { - return Self(event: AnalyticsEvent.download(action: action, source: source, urn: urn)) + Self(event: AnalyticsEvent.download(action: action, source: source, urn: urn)) } - + @objc class func favorite(action: AnalyticsListAction, source: AnalyticsListSource, urn: String?) -> AnalyticsEventObjC { - return Self(event: AnalyticsEvent.favorite(action: action, source: source, urn: urn)) + Self(event: AnalyticsEvent.favorite(action: action, source: source, urn: urn)) } - + @objc class func googleGast(urn: String) -> AnalyticsEventObjC { - return Self(event: AnalyticsEvent.googleGast(urn: urn)) + Self(event: AnalyticsEvent.googleGast(urn: urn)) } - + @objc class func identity(action: AnalyticsIdentityAction) -> AnalyticsEventObjC { - return Self(event: AnalyticsEvent.identity(action: action)) + Self(event: AnalyticsEvent.identity(action: action)) } - + @objc class func notification(action: AnalyticsNotificationAction, from: AnalyticsNotificationFrom, uid: String, overrideSource: String? = nil, overrideType: String? = nil) -> AnalyticsEventObjC { - return Self(event: AnalyticsEvent.notification(action: action, from: from, uid: uid, overrideSource: overrideSource, overrideType: overrideType)) + Self(event: AnalyticsEvent.notification(action: action, from: from, uid: uid, overrideSource: overrideSource, overrideType: overrideType)) } - + @objc class func openUrl(action: AnalyticsOpenUrlAction, source: AnalyticsOpenUrlSource, urn: String?) -> AnalyticsEventObjC { - return Self(event: AnalyticsEvent.openUrl(action: action, source: source, urn: urn)) + Self(event: AnalyticsEvent.openUrl(action: action, source: source, urn: urn)) } - + @objc class func pictureInPicture(urn: String?) -> AnalyticsEventObjC { - return Self(event: AnalyticsEvent.pictureInPicture(urn: urn)) + Self(event: AnalyticsEvent.pictureInPicture(urn: urn)) } - + @objc class func sharing(action: AnalyticsSharingAction, uid: String, mediaContentType: AnalyticsSharingMediaContentType, source: AnalyticsSharingSource, type: String?) -> AnalyticsEventObjC { - return Self(event: AnalyticsEvent.sharing(action: action, uid: uid, mediaContentType: mediaContentType, source: source, type: type)) + Self(event: AnalyticsEvent.sharing(action: action, uid: uid, mediaContentType: mediaContentType, source: source, type: type)) } - + @objc class func shortcutItem(action: AnalyticsShortcutItemAction) -> AnalyticsEventObjC { - return Self(event: AnalyticsEvent.shortcutItem(action: action)) + Self(event: AnalyticsEvent.shortcutItem(action: action)) } - + @objc class func userActivity(action: AnalyticsUserActivityAction, urn: String) -> AnalyticsEventObjC { - return Self(event: AnalyticsEvent.userActivity(action: action, urn: urn)) + Self(event: AnalyticsEvent.userActivity(action: action, urn: urn)) } - + @objc class func watchLater(action: AnalyticsListAction, source: AnalyticsListSource, urn: String?) -> AnalyticsEventObjC { - return Self(event: AnalyticsEvent.watchLater(action: action, source: source, urn: urn)) + Self(event: AnalyticsEvent.watchLater(action: action, source: source, urn: urn)) } - + required init(event: AnalyticsEvent) { self.event = event } @@ -243,40 +243,40 @@ struct AnalyticsEvent { @objc enum AnalyticsListAction: UInt { case add case remove - + fileprivate var downloadName: String { switch self { case .add: - return "download_add" + "download_add" case .remove: - return "download_remove" + "download_remove" } } - + fileprivate var favoriteName: String { switch self { case .add: - return "favorite_add" + "favorite_add" case .remove: - return "favorite_remove" + "favorite_remove" } } - + fileprivate var subscriptionName: String { switch self { case .add: - return "subscription_add" + "subscription_add" case .remove: - return "subscription_remove" + "subscription_remove" } } - + fileprivate var watchLaterName: String { switch self { case .add: - return "watch_later_add" + "watch_later_add" case .remove: - return "watch_later_remove" + "watch_later_remove" } } } @@ -285,15 +285,15 @@ struct AnalyticsEvent { case button case contextMenu case selection - + fileprivate var value: String { switch self { case .button: - return "button" + "button" case .contextMenu: - return "context_menu" + "context_menu" case .selection: - return "selection" + "selection" } } } @@ -303,24 +303,24 @@ struct AnalyticsEvent { case playAutomatic case play case cancel - + fileprivate var source: String { switch self { case .display, .playAutomatic: - return "automatic" + "automatic" case .play, .cancel: - return "button" + "button" } } - + fileprivate var type: String { switch self { case .display: - return "display" + "display" case .playAutomatic, .play: - return "play_media" + "play_media" case .cancel: - return "cancel" + "cancel" } } } @@ -329,15 +329,15 @@ struct AnalyticsEvent { case playMedia case displayShow case alert - + fileprivate var type: String { switch self { case .playMedia: - return "play_media" + "play_media" case .displayShow: - return "display_show" + "display_show" case .alert: - return "notification_alert" + "notification_alert" } } } @@ -348,19 +348,19 @@ struct AnalyticsEvent { case displayShow case displayPage case displayUrl - + fileprivate var type: String { switch self { case .openPlayApp: - return "open_play_app" + "open_play_app" case .playMedia: - return "play_media" + "play_media" case .displayShow: - return "display_show" + "display_show" case .displayPage: - return "display_page" + "display_page" case .displayUrl: - return "display_url" + "display_url" } } } @@ -368,13 +368,13 @@ struct AnalyticsEvent { @objc enum AnalyticsOpenUrlSource: UInt { case customURL case universalLink - + fileprivate var value: String { switch self { case .customURL: - return "scheme_url" + "scheme_url" case .universalLink: - return "deep_link" + "deep_link" } } } @@ -384,17 +384,17 @@ struct AnalyticsEvent { case technicalIssue case feedbackApp case evaluateApp - + fileprivate var name: String { switch self { case .faq: - return "faq_open" + "faq_open" case .technicalIssue: - return "technical_issue_open" + "technical_issue_open" case .feedbackApp: - return "feedback_app_open" + "feedback_app_open" case .evaluateApp: - return "evaluate_app_open" + "evaluate_app_open" } } } @@ -405,7 +405,7 @@ struct AnalyticsEvent { case login case logout case unexpectedLogout - + fileprivate var labels: SRGAnalyticsEventLabels { let labels = SRGAnalyticsEventLabels() switch self { @@ -431,22 +431,22 @@ struct AnalyticsEvent { @objc enum AnalyticsNotificationFrom: UInt { case application case operatingSystem - + fileprivate var name: String { switch self { case .application: - return "notification_open" + "notification_open" case .operatingSystem: - return "push_notification_open" + "push_notification_open" } } - + fileprivate var source: String { switch self { case .application: - return "notification" + "notification" case .operatingSystem: - return "push_notification" + "push_notification" } } } @@ -457,19 +457,19 @@ struct AnalyticsEvent { case page case microPage case section - + fileprivate var name: String { switch self { case .media: - return "media_share" + "media_share" case .show: - return "show_share" + "show_share" case .page: - return "page_share" + "page_share" case .microPage: - return "micro_page_share" + "micro_page_share" case .section: - return "section_share" + "section_share" } } } @@ -479,17 +479,17 @@ struct AnalyticsEvent { case content case contentAtTime case currentClip - + fileprivate var value: String? { switch self { case .content: - return "content" + "content" case .contentAtTime: - return "content_at_time" + "content_at_time" case .currentClip: - return "current_clip" + "current_clip" default: - return nil + nil } } } @@ -497,13 +497,13 @@ struct AnalyticsEvent { @objc enum AnalyticsSharingSource: UInt { case button case contextMenu - + fileprivate var value: String { switch self { case .button: - return "button" + "button" case .contextMenu: - return "context_menu" + "context_menu" } } } @@ -513,17 +513,17 @@ struct AnalyticsEvent { case downloads case history case search - + fileprivate var type: String { switch self { case .favorites: - return "openfavorites" + "openfavorites" case .downloads: - return "opendownloads" + "opendownloads" case .history: - return "openhistory" + "openhistory" case .search: - return "opensearch" + "opensearch" } } } @@ -531,13 +531,13 @@ struct AnalyticsEvent { @objc enum AnalyticsUserActivityAction: UInt { case playMedia case displayShow - + fileprivate var type: String { switch self { case .playMedia: - return "play_media" + "play_media" case .displayShow: - return "display_show" + "display_show" } } } diff --git a/Application/Sources/Helpers/Categories/UIDevice+PlaySRG.m b/Application/Sources/Helpers/Categories/UIDevice+PlaySRG.m index b5052b0ab..baf2b3e86 100755 --- a/Application/Sources/Helpers/Categories/UIDevice+PlaySRG.m +++ b/Application/Sources/Helpers/Categories/UIDevice+PlaySRG.m @@ -88,12 +88,12 @@ static UIInterfaceOrientationMask MediaPlayerUserInterfaceOrientationMask(UIInte return UIInterfaceOrientationMaskLandscapeLeft; break; } - + case UIInterfaceOrientationLandscapeRight: { return UIInterfaceOrientationMaskLandscapeRight; break; } - + case UIInterfaceOrientationPortraitUpsideDown: { return UIInterfaceOrientationMaskPortraitUpsideDown; break; diff --git a/Application/Sources/Helpers/Categories/UIImageView+PlaySRG.m b/Application/Sources/Helpers/Categories/UIImageView+PlaySRG.m index 7c1e48042..1f165d903 100755 --- a/Application/Sources/Helpers/Categories/UIImageView+PlaySRG.m +++ b/Application/Sources/Helpers/Categories/UIImageView+PlaySRG.m @@ -170,7 +170,7 @@ - (void)play_requestImage:(SRGImage *)image // Fix for invalid images, incorrect Kids program images, and incorrect images for sports (RTS) // See https://srfmmz.atlassian.net/browse/AIS-15672 if (! URL || [URL.absoluteString containsString:@"NOT_SPECIFIED.jpg"] || [URL.absoluteString containsString:@"rts.ch/video/jeunesse"] - || [URL.absoluteString containsString:@".html"]) { + || [URL.absoluteString containsString:@".html"]) { handleUnavailableURL(); return; } diff --git a/Application/Sources/Helpers/Categories/UILabel+PlaySRG.h b/Application/Sources/Helpers/Categories/UILabel+PlaySRG.h index 3ec51b0b4..23bf49454 100755 --- a/Application/Sources/Helpers/Categories/UILabel+PlaySRG.h +++ b/Application/Sources/Helpers/Categories/UILabel+PlaySRG.h @@ -18,7 +18,7 @@ NS_ASSUME_NONNULL_BEGIN /** * Call to display the standard "web first" badge. -*/ + */ - (void)play_setWebFirstBadge; @end diff --git a/Application/Sources/Helpers/Categories/UIView+PlaySRG.m b/Application/Sources/Helpers/Categories/UIView+PlaySRG.m index 7aa77b406..5f26d3438 100755 --- a/Application/Sources/Helpers/Categories/UIView+PlaySRG.m +++ b/Application/Sources/Helpers/Categories/UIView+PlaySRG.m @@ -28,7 +28,7 @@ - (UIViewController *)play_nearestViewController while (responder) { if ([responder isKindOfClass:UIViewController.class] && // Ignore SwiftUI hosting view controllers - [NSStringFromClass(responder.class) rangeOfString:@"SwiftUI"].location == NSNotFound) { + [NSStringFromClass(responder.class) rangeOfString:@"SwiftUI"].location == NSNotFound) { return (UIViewController *)responder; } responder = responder.nextResponder; diff --git a/Application/Sources/Helpers/Categories/UIViewController+PlaySRG.m b/Application/Sources/Helpers/Categories/UIViewController+PlaySRG.m index 0fc0700a9..1d46f232d 100755 --- a/Application/Sources/Helpers/Categories/UIViewController+PlaySRG.m +++ b/Application/Sources/Helpers/Categories/UIViewController+PlaySRG.m @@ -202,8 +202,8 @@ - (void)play_presentNativeMediaPlayerWithMedia:(SRGMedia *)media position:(SRGPo letterboxController.playbackTransitionDelegate = playlist; if ([letterboxController.media isEqual:media] && letterboxController.playbackState != SRGMediaPlayerPlaybackStateIdle - && letterboxController.playbackState != SRGMediaPlayerPlaybackStatePreparing - && letterboxController.playbackState != SRGMediaPlayerPlaybackStateEnded) { + && letterboxController.playbackState != SRGMediaPlayerPlaybackStatePreparing + && letterboxController.playbackState != SRGMediaPlayerPlaybackStateEnded) { [letterboxController seekToPosition:position withCompletionHandler:^(BOOL finished) { [letterboxController play]; }]; diff --git a/Application/Sources/Helpers/CollectionRow.swift b/Application/Sources/Helpers/CollectionRow.swift index eb9a9e3ec..a4af7a3d5 100644 --- a/Application/Sources/Helpers/CollectionRow.swift +++ b/Application/Sources/Helpers/CollectionRow.swift @@ -14,9 +14,9 @@ struct CollectionRow: Hashable { let section: Section /// Items contained within the section. let items: [Item] - + var isEmpty: Bool { - return items.isEmpty + items.isEmpty } } @@ -28,7 +28,7 @@ struct NonEmptyCollectionRow: Hashable { let section: Section /// Items contained within the section. let items: [Item] - + init?(section: Section, items: [Item]) { guard !items.isEmpty else { return nil } self.section = section diff --git a/Application/Sources/Helpers/Colors.swift b/Application/Sources/Helpers/Colors.swift index 334a3d3e2..05d896258 100644 --- a/Application/Sources/Helpers/Colors.swift +++ b/Application/Sources/Helpers/Colors.swift @@ -14,18 +14,18 @@ extension Color { extension UIColor { func image(ofSize size: CGSize = CGSize(width: 1, height: 1)) -> UIImage { - return UIGraphicsImageRenderer(size: size).image { context in + UIGraphicsImageRenderer(size: size).image { context in self.setFill() context.fill(CGRect(origin: .zero, size: size)) } } - + static var placeholder = UIColor(white: 1, alpha: 0.1) @objc static var thumbnailBackground = UIColor.black - -#if DEBUG - static func random(alpha: CGFloat = 1) -> UIColor { - return UIColor(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1), alpha: alpha) - } -#endif + + #if DEBUG + static func random(alpha: CGFloat = 1) -> UIColor { + UIColor(red: .random(in: 0 ... 1), green: .random(in: 0 ... 1), blue: .random(in: 0 ... 1), alpha: alpha) + } + #endif } diff --git a/Application/Sources/Helpers/Extensions.swift b/Application/Sources/Helpers/Extensions.swift index 21188bf27..55c5fcd85 100644 --- a/Application/Sources/Helpers/Extensions.swift +++ b/Application/Sources/Helpers/Extensions.swift @@ -11,11 +11,11 @@ import SRGDataProviderCombine import SwiftUI func constant(iOS: T, tvOS: T) -> T { -#if os(tvOS) - return tvOS -#else - return iOS -#endif + #if os(tvOS) + return tvOS + #else + return iOS + #endif } /** @@ -25,7 +25,7 @@ func constant(iOS: T, tvOS: T) -> T { */ func removeDuplicates(in items: [T]) -> [T] { var itemDictionnary = [T: Bool]() - + return items.filter { let isNew = itemDictionnary.updateValue(true, forKey: $0) == nil if !isNew { @@ -36,86 +36,87 @@ func removeDuplicates(in items: [T]) -> [T] { } func url(for image: SRGImage?, size: SRGImageSize) -> URL? { - return SRGDataProvider.current!.url(for: image, size: size) + SRGDataProvider.current!.url(for: image, size: size) } extension Comparable { func clamped(to range: ClosedRange) -> Self { - return min(max(self, range.lowerBound), range.upperBound) + min(max(self, range.lowerBound), range.upperBound) } } extension Float { // See https://stackoverflow.com/a/31390678/760435 var minimalRepresentation: String { - return truncatingRemainder(dividingBy: 1) == 0 ? String(format: "%.0f", self) : String(self) + truncatingRemainder(dividingBy: 1) == 0 ? String(format: "%.0f", self) : String(self) } } extension String { static func placeholder(length: Int) -> String { - return String(repeating: " ", count: length) + String(repeating: " ", count: length) } - + static let loremIpsum: String = """ - Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et \ - dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. \ - Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, \ - consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, \ - sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no \ - sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, \ - sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero \ - eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. \ - Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et \ - dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. \ - Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, \ - consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, \ - sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no \ - sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, \ - sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero \ - eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. \ - Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et \ - dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. \ - Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, \ - consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, \ - sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no \ - sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, \ - sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero \ - eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. - """ - + Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et \ + dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. \ + Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, \ + consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, \ + sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no \ + sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, \ + sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero \ + eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. \ + Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et \ + dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. \ + Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, \ + consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, \ + sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no \ + sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, \ + sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero \ + eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. \ + Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et \ + dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. \ + Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, \ + consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, \ + sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no \ + sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, \ + sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero \ + eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. + """ + static let loremIpsumWithSpacesAndNewLine: String = """ - \r\n Lorem ipsum dolor sit amet.\r\n\r\n\rConsetetur sadipscing elitr, sed diam \ - nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.\r\n - """ - + \r\n Lorem ipsum dolor sit amet.\r\n\r\n\rConsetetur sadipscing elitr, sed diam \ + nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.\r\n + """ + func unobfuscated() -> String { - return components(separatedBy: .decimalDigits).joined() + components(separatedBy: .decimalDigits).joined() } - + var capitalizedFirstLetter: String { - return prefix(1).capitalized + dropFirst() + prefix(1).capitalized + dropFirst() } - + func heightOfString(usingFontStyle fontStyle: SRGFont.Style) -> CGFloat { - let font = SRGFont.font(fontStyle) as UIFont - let fontAttributes = [NSAttributedString.Key.font: font] - let size = self.size(withAttributes: fontAttributes) - return size.height + sizeOfString(usingFontStyle: fontStyle).height } - + func widthOfString(usingFontStyle fontStyle: SRGFont.Style) -> CGFloat { + sizeOfString(usingFontStyle: fontStyle).width + } + + private func sizeOfString(usingFontStyle fontStyle: SRGFont.Style) -> CGSize { let font = SRGFont.font(fontStyle) as UIFont - let fontAttributes = [NSAttributedString.Key.font: font] - let size = self.size(withAttributes: fontAttributes) - return size.width + let attributes = [NSAttributedString.Key.font: font] + let attributedString = NSAttributedString(string: self, attributes: attributes) + return attributedString.size() } - + /* * Compact the string to not contain any empty lines or white spaces. */ var compacted: String { - return self.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression).trimmingCharacters(in: .whitespaces) + replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression).trimmingCharacters(in: .whitespaces) } } @@ -125,28 +126,27 @@ extension Array { array.append(newElement) return array } - + func appending(contentsOf newElements: S) -> Array where Element == S.Element, S: Sequence { var array = self array.append(contentsOf: newElements) return array } - + subscript(safeIndex index: Int) -> Element? { guard index >= 0, index < endIndex else { return nil } return self[index] } - + func median() -> Element? where Element: FloatingPoint { guard !isEmpty else { return nil } - + let sortedSelf = sorted() let count = sortedSelf.count - + if count.isMultiple(of: 2) { return (sortedSelf[count / 2 - 1] + sortedSelf[count / 2]) / 2 - } - else { + } else { return sortedSelf[count / 2] } } @@ -164,7 +164,7 @@ extension Collection { return transformedElement } } - + /** * Transform each item in a collection (getting rid of `nil` items), providing an auto-increased index with each * processed item. @@ -177,20 +177,19 @@ extension Collection { return transformedElement } } - + /** * Groups items from the receiver into an alphabetical list. Preserves the initial ordering in each group, * and collects items starting with non-letter characters under '#'. If a group is present in the returned * array the array of associated items is guaranteed to contain at least 1 item. */ - func groupedAlphabetically(by keyForElement: (Self.Element) throws -> S?) rethrows -> [(key: Character, value: [Self.Element])] where S: StringProtocol { + func groupedAlphabetically(by keyForElement: (Self.Element) throws -> (some StringProtocol)?) rethrows -> [(key: Character, value: [Self.Element])] { let dictionary = try [Character: [Self.Element]](grouping: self) { element in if let key = try keyForElement(element), let character = key.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: nil).first, character.isLetter { - return character - } - else { - return "#" + character + } else { + "#" } } let hashGroup = dictionary["#"] @@ -234,25 +233,24 @@ extension View { if let label, !label.isEmpty { // FIXME: Accessibility hints are currently buggy with SwiftUI on tvOS. Applying a hint makes VoiceOver tell only the hint, // forgetting about the label. Until this is fixed by Apple we must avoid applying hints on tvOS. -#if os(tvOS) - accessibilityHidden(true) - .accessibilityElement() - .accessibilityLabel(label) - .accessibilityAddTraits(traits) -#else - accessibilityHidden(true) - .accessibilityElement() - .accessibilityLabel(label) - .accessibilityHint(hint ?? "") - .accessibilityAddTraits(traits) -#endif - } - else { + #if os(tvOS) + accessibilityHidden(true) + .accessibilityElement() + .accessibilityLabel(label) + .accessibilityAddTraits(traits) + #else + accessibilityHidden(true) + .accessibilityElement() + .accessibilityLabel(label) + .accessibilityHint(hint ?? "") + .accessibilityAddTraits(traits) + #endif + } else { accessibilityHidden(true) } } } - + /** * Calculate the size of a SwiftUI view provided with some parent size, and for the specified horizontal size class. * @@ -260,14 +258,14 @@ extension View { * all the provided space in this direction. */ func adaptiveSizeThatFits(in size: CGSize, for horizontalSizeClass: UIUserInterfaceSizeClass) -> CGSize { -#if os(iOS) - let hostController = UIHostingController(rootView: self.environment(\.horizontalSizeClass, UserInterfaceSizeClass(horizontalSizeClass))) -#else - let hostController = UIHostingController(rootView: self) -#endif + #if os(iOS) + let hostController = UIHostingController(rootView: environment(\.horizontalSizeClass, UserInterfaceSizeClass(horizontalSizeClass))) + #else + let hostController = UIHostingController(rootView: self) + #endif return hostController.sizeThatFits(in: size) } - + /** * Read the size of a view and provides it to the specified closure. * @@ -284,7 +282,7 @@ extension View { ) .onPreferenceChange(SizePreferenceKey.self, perform: onChange) } - + /** * Small helper to build a frame with a size. */ @@ -295,15 +293,15 @@ extension View { private struct SizePreferenceKey: PreferenceKey { static var defaultValue: CGSize = .zero - static func reduce(value: inout CGSize, nextValue: () -> CGSize) {} + static func reduce(value _: inout CGSize, nextValue _: () -> CGSize) {} } /** * Available selection styles. */ enum SelectionAppearance { - case dimmed // The view is dimmed. - case transluscent // The view is slightly transluscent. + case dimmed // The view is dimmed. + case transluscent // The view is slightly transluscent. } extension View { @@ -311,7 +309,7 @@ extension View { * Adjust the selection appearance of the receiver, applying one of the available styles. */ func selectionAppearance(_ appearance: SelectionAppearance = .dimmed, when selected: Bool, while editing: Bool = false) -> some View { - return Group { + Group { if (!editing && selected) || (editing && !selected) { switch appearance { case .dimmed: @@ -319,8 +317,7 @@ extension View { case .transluscent: opacity(0.5) } - } - else { + } else { self } } @@ -331,30 +328,29 @@ extension View { extension UIHostingController { public convenience init(rootView: Content, ignoreSafeArea: Bool) { self.init(rootView: rootView) - + if ignoreSafeArea { disableSafeArea() } } - + func disableSafeArea() { guard let viewClass = object_getClass(view) else { return } - + let viewSubclassName = String(cString: class_getName(viewClass)).appending("_IgnoreSafeArea") if let viewSubclass = NSClassFromString(viewSubclassName) { object_setClass(view, viewSubclass) - } - else { + } else { guard let viewClassNameUtf8 = (viewSubclassName as NSString).utf8String else { return } guard let viewSubclass = objc_allocateClassPair(viewClass, viewClassNameUtf8, 0) else { return } - + if let method = class_getInstanceMethod(UIView.self, #selector(getter: UIView.safeAreaInsets)) { let safeAreaInsets: @convention(block) (AnyObject) -> UIEdgeInsets = { _ in - return .zero + .zero } class_addMethod(viewSubclass, #selector(getter: UIView.safeAreaInsets), imp_implementationWithBlock(safeAreaInsets), method_getTypeEncoding(method)) } - + objc_registerClassPair(viewSubclass) object_setClass(view, viewSubclass) } @@ -363,34 +359,34 @@ extension UIHostingController { extension NSCollectionLayoutSection { typealias CellSizer = (_ layoutWidth: CGFloat, _ spacing: CGFloat) -> NSCollectionLayoutSize - + static func horizontal(layoutWidth: CGFloat, horizontalMargin: CGFloat = 0, spacing: CGFloat = 0, top: CGFloat = 0, bottom: CGFloat = 0, cellSizer: CellSizer) -> NSCollectionLayoutSection { let effectiveLayoutWidth = layoutWidth - 2 * horizontalMargin let cellSize = cellSizer(effectiveLayoutWidth, spacing) - + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)) let item = NSCollectionLayoutItem(layoutSize: itemSize) - + let groupSize = NSCollectionLayoutSize(widthDimension: cellSize.widthDimension, heightDimension: cellSize.heightDimension) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) - + let section = NSCollectionLayoutSection(group: group) section.interGroupSpacing = spacing section.contentInsets = NSDirectionalEdgeInsets(top: top, leading: horizontalMargin, bottom: bottom, trailing: horizontalMargin) return section } - + static func grid(layoutWidth: CGFloat, horizontalMargin: CGFloat = 0, spacing: CGFloat = 0, top: CGFloat = 0, bottom: CGFloat = 0, cellSizer: CellSizer) -> NSCollectionLayoutSection { let effectiveLayoutWidth = layoutWidth - 2 * horizontalMargin let cellSize = cellSizer(effectiveLayoutWidth, spacing) - + let itemSize = NSCollectionLayoutSize(widthDimension: cellSize.widthDimension, heightDimension: .fractionalHeight(1)) let item = NSCollectionLayoutItem(layoutSize: itemSize) - + let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(layoutWidth), heightDimension: cellSize.heightDimension) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) group.interItemSpacing = .fixed(spacing) - + let section = NSCollectionLayoutSection(group: group) section.interGroupSpacing = spacing section.contentInsets = NSDirectionalEdgeInsets(top: top, leading: horizontalMargin, bottom: bottom, trailing: horizontalMargin) @@ -401,42 +397,40 @@ extension NSCollectionLayoutSection { extension NSCollectionLayoutDimension { func constrained(to size: CGSize) -> CGFloat { if isFractionalWidth { - return size.width * dimension - } - else if isFractionalHeight { - return size.height * dimension - } - else { - return dimension + size.width * dimension + } else if isFractionalHeight { + size.height * dimension + } else { + dimension } } } extension NSCollectionLayoutSize { private static let defaultSize: CGSize = constant(iOS: CGSize(width: 750, height: 1334), tvOS: CGSize(width: 1920, height: 1080)) - + @objc func constrained(to size: CGSize) -> CGSize { let width = widthDimension.constrained(to: size) let height = heightDimension.constrained(to: size) return CGSize(width: width, height: height) } - + @objc func constrained(by view: UIView) -> CGSize { - return constrained(to: view.frame.size) + constrained(to: view.frame.size) } - + var previewSize: CGSize { - return constrained(to: Self.defaultSize) + constrained(to: Self.defaultSize) } } extension View { func horizontalSizeClass(_ sizeClass: UIUserInterfaceSizeClass) -> some View { -#if os(iOS) - return environment(\.horizontalSizeClass, UserInterfaceSizeClass(sizeClass)) -#else - return self -#endif + #if os(iOS) + return environment(\.horizontalSizeClass, UserInterfaceSizeClass(sizeClass)) + #else + return self + #endif } } @@ -448,19 +442,18 @@ extension UIView { /// The view takes as much space as offered. case expanding } - + /// Probe some hosting controller to determine the behavior of its SwiftUI view in some direction. - private func sizingBehavior(of hostingController: UIHostingController, for axis: NSLayoutConstraint.Axis) -> SizingBehavior { + private func sizingBehavior(of hostingController: UIHostingController, for axis: NSLayoutConstraint.Axis) -> SizingBehavior { // Fit into the maximal allowed layout size to check which boundaries are adopted by the associated view let size = hostingController.sizeThatFits(in: UIView.layoutFittingExpandedSize) if axis == .vertical { return size.height == UIView.layoutFittingExpandedSize.height ? .expanding : .hugging - } - else { + } else { return size.width == UIView.layoutFittingExpandedSize.width ? .expanding : .hugging } } - + /// Apply the specified sizing behavior in some direction. func applySizingBehavior(_ sizingBehavior: SizingBehavior, for axis: NSLayoutConstraint.Axis) { switch sizingBehavior { @@ -472,20 +465,20 @@ extension UIView { setContentCompressionResistancePriority(UILayoutPriority(0), for: axis) } } - + /// Apply the specified sizing behavior in all directions. func applySizingBehavior(_ sizingBehavior: SizingBehavior) { applySizingBehavior(sizingBehavior, for: .horizontal) applySizingBehavior(sizingBehavior, for: .vertical) } - + /// Apply the same sizing behavior as the provided hosting controller in some directions (layout neutrality). - func applySizingBehavior(of hostingController: UIHostingController, for axis: NSLayoutConstraint.Axis) { + func applySizingBehavior(of hostingController: UIHostingController, for axis: NSLayoutConstraint.Axis) { applySizingBehavior(sizingBehavior(of: hostingController, for: axis), for: axis) } - + /// Apply the same sizing behavior as the provided hosting controller in all directions (layout neutrality). - func applySizingBehavior(of hostingController: UIHostingController) { + func applySizingBehavior(of hostingController: UIHostingController) { applySizingBehavior(of: hostingController, for: .horizontal) applySizingBehavior(of: hostingController, for: .vertical) } @@ -495,18 +488,18 @@ extension UIViewController { func deselectItems(in collectionView: UICollectionView, animated: Bool) { guard let selectedIndexPaths = collectionView.indexPathsForSelectedItems else { return } guard animated, let transitionCoordinator, transitionCoordinator.animate(alongsideTransition: { context in - selectedIndexPaths.forEach { indexPath in - collectionView.deselectItem(at: indexPath, animated: context.isAnimated) - } - }, completion: { context in - if context.isCancelled { - selectedIndexPaths.forEach { indexPath in - collectionView.selectItem(at: indexPath, animated: false, scrollPosition: []) - } + for indexPath in selectedIndexPaths { + collectionView.deselectItem(at: indexPath, animated: context.isAnimated) + } + }, completion: { context in + if context.isCancelled { + for indexPath in selectedIndexPaths { + collectionView.selectItem(at: indexPath, animated: false, scrollPosition: []) } - }) + } + }) else { - selectedIndexPaths.forEach { indexPath in + for indexPath in selectedIndexPaths { collectionView.deselectItem(at: indexPath, animated: animated) } return @@ -518,7 +511,7 @@ extension View { func eraseToAnyView() -> AnyView { AnyView(self) } - + @ViewBuilder func play_scrollClipDisabled() -> some View { if #available(iOS 17, tvOS 17, *) { @@ -532,53 +525,51 @@ extension View { extension UIApplication { /// Return the main window scene among all connected scenes, if any. @objc var mainWindowScene: UIWindowScene? { - return connectedScenes + connectedScenes .filter { $0.delegate is SceneDelegate } .compactMap { $0 as? UIWindowScene } .first } - + /// Return the main key window among all connected scenes, if any. @objc var mainWindow: UIWindow? { - return mainWindowScene?.windows + mainWindowScene?.windows .first { $0.isKeyWindow } } - + /// Return the main scene delegate, if any. @objc var mainSceneDelegate: SceneDelegate? { - return mainWindowScene?.delegate as? SceneDelegate + mainWindowScene?.delegate as? SceneDelegate } - + /// Return the main top view controller, if any. @objc var mainTopViewController: UIViewController? { - return mainWindow?.play_topViewController - } - -#if os(iOS) - /// Return the main tab bar root controller, if any. - @objc var mainTabBarController: TabBarController? { - return mainWindow?.rootViewController as? TabBarController + mainWindow?.play_topViewController } -#endif + + #if os(iOS) + /// Return the main tab bar root controller, if any. + @objc var mainTabBarController: TabBarController? { + mainWindow?.rootViewController as? TabBarController + } + #endif } extension Locale { - static let currentLanguageIdentifier: String = { - if #available(iOS 16, tvOS 16, *) { - return Locale.current.identifier(.bcp47) - } else { - return Locale.current.identifier.replacingOccurrences(of: "_", with: "-") - } - }() + static let currentLanguageIdentifier: String = if #available(iOS 16, tvOS 16, *) { + Locale.current.identifier(.bcp47) + } else { + Locale.current.identifier.replacingOccurrences(of: "_", with: "-") + } } extension SRGAnalyticsLabels { @objc class var play_globalLabels: SRGAnalyticsLabels { let analyticsLabels = UserConsentHelper.srgAnalyticsLabels var customInfo: [String: String] = analyticsLabels.customInfo ?? [:] - + customInfo["navigation_app_language"] = Locale.currentLanguageIdentifier - + analyticsLabels.customInfo = customInfo return analyticsLabels } diff --git a/Application/Sources/Helpers/Extensions/Bundble+PlaySRG.swift b/Application/Sources/Helpers/Extensions/Bundble+PlaySRG.swift index b1c878670..8ec0bda12 100644 --- a/Application/Sources/Helpers/Extensions/Bundble+PlaySRG.swift +++ b/Application/Sources/Helpers/Extensions/Bundble+PlaySRG.swift @@ -9,15 +9,15 @@ import Foundation /** * Return an accessibility-oriented localized string from the main bundle. */ -func PlaySRGAccessibilityLocalizedString(_ key: String, comment: String?) -> String { - return Bundle.main.localizedString(forKey: key, value: "", table: "Accessibility") +func PlaySRGAccessibilityLocalizedString(_ key: String, comment _: String?) -> String { + Bundle.main.localizedString(forKey: key, value: "", table: "Accessibility") } /** * Return an onboarding localized string from the main bundle. */ -func PlaySRGOnboardingLocalizedString(_ key: String, comment: String?) -> String { - return Bundle.main.localizedString(forKey: key, value: "", table: "Onboarding") +func PlaySRGOnboardingLocalizedString(_ key: String, comment _: String?) -> String { + Bundle.main.localizedString(forKey: key, value: "", table: "Onboarding") } /** @@ -26,45 +26,45 @@ func PlaySRGOnboardingLocalizedString(_ key: String, comment: String?) -> String * See https://clang-analyzer.llvm.org/faq.html. */ func PlaySRGNonLocalizedString(_ string: String) -> String { - return string + string } extension Bundle { @objc static func PlaySRGAccessibilityLocalizedString(_ key: String, _ comment: String?) -> String { - return PlaySRG.PlaySRGAccessibilityLocalizedString(key, comment: comment) + PlaySRG.PlaySRGAccessibilityLocalizedString(key, comment: comment) } - + var play_friendlyVersionNumber: String { - let shortVersionString = self.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" + let shortVersionString = infoDictionary?["CFBundleShortVersionString"] as? String ?? "" let marketingVersion = shortVersionString.components(separatedBy: "-").first ?? shortVersionString - - let bundleVersion = self.infoDictionary?["CFBundleVersion"] as? String ?? "" - - let bundleDisplayNameSuffix = self.infoDictionary?["BundleDisplayNameSuffix"] as? String ?? "" - let buildName = self.infoDictionary?["BuildName"] as? String ?? "" + + let bundleVersion = infoDictionary?["CFBundleVersion"] as? String ?? "" + + let bundleDisplayNameSuffix = infoDictionary?["BundleDisplayNameSuffix"] as? String ?? "" + let buildName = infoDictionary?["BuildName"] as? String ?? "" let friendlyBuildName = "\(bundleDisplayNameSuffix)\(buildName.isEmpty ? "" : " \(buildName)")" - + var version = "\(marketingVersion) (\(bundleVersion))\(friendlyBuildName)" - if self.play_isTestFlightDistribution { + if play_isTestFlightDistribution { // Unbreakable spaces before / after the separator version += " - TF" } return version } - + var play_isTestFlightDistribution: Bool { -#if !DEBUG && !APPCENTER - return (self.appStoreReceiptURL?.path ?? "").contains("sandboxReceipt") -#else - return false -#endif + #if !DEBUG && !APPCENTER + return (appStoreReceiptURL?.path ?? "").contains("sandboxReceipt") + #else + return false + #endif } - + var play_isAppStoreRelease: Bool { -#if DEBUG || NIGHTLY || BETA - return false -#else - return !self.play_isTestFlightDistribution -#endif + #if DEBUG || NIGHTLY || BETA + return false + #else + return !play_isTestFlightDistribution + #endif } } diff --git a/Application/Sources/Helpers/Extensions/DateFormatter+playSRG.swift b/Application/Sources/Helpers/Extensions/DateFormatter+playSRG.swift index b26a762ad..329ac34bc 100644 --- a/Application/Sources/Helpers/Extensions/DateFormatter+playSRG.swift +++ b/Application/Sources/Helpers/Extensions/DateFormatter+playSRG.swift @@ -19,7 +19,7 @@ extension DateFormatter { dateFormatter.timeStyle = .short return dateFormatter }() - + /** * Absolute short date formatting. * @@ -32,7 +32,7 @@ extension DateFormatter { dateFormatter.timeStyle = .none return dateFormatter }() - + /** * Absolute date and time short formatting. * @@ -45,7 +45,7 @@ extension DateFormatter { dateFormatter.timeStyle = .short return dateFormatter }() - + /** * Relative date and time formatting, i.e. displays today / yesterday / tomorrow / ... for dates near today. * @@ -59,7 +59,7 @@ extension DateFormatter { dateFormatter.doesRelativeDateFormatting = true return dateFormatter }() - + /** * Relative date formatting, i.e. displays today / yesterday / tomorrow / ... for dates near today, otherwise * the date in a long format. @@ -74,7 +74,7 @@ extension DateFormatter { dateFormatter.doesRelativeDateFormatting = true return dateFormatter }() - + /** * Relative date formatting, i.e. displays today / yesterday / tomorrow / ... for dates near today, otherwise * the date in a full format. @@ -89,7 +89,7 @@ extension DateFormatter { dateFormatter.doesRelativeDateFormatting = true return dateFormatter }() - + /** * Relative date formatting, i.e. displays today / yesterday / tomorrow / ... for dates near today, otherwise * the date in a short format. @@ -104,7 +104,7 @@ extension DateFormatter { dateFormatter.doesRelativeDateFormatting = true return dateFormatter }() - + /** * Relative date formatting, i.e. displays today / yesterday / tomorrow / ... for dates near today, otherwise * the date in a short format and time in a short format. @@ -119,7 +119,7 @@ extension DateFormatter { dateFormatter.doesRelativeDateFormatting = true return dateFormatter }() - + /** * ISO 8601 calendar date formatter. */ @@ -129,7 +129,7 @@ extension DateFormatter { dateFormatter.dateFormat = "yyyy-MM-dd" return dateFormatter }() - + /** * RFC 3339 date formatter. */ diff --git a/Application/Sources/Helpers/Extensions/NSArray+PlaySRG.swift b/Application/Sources/Helpers/Extensions/NSArray+PlaySRG.swift index 755709082..a83894482 100644 --- a/Application/Sources/Helpers/Extensions/NSArray+PlaySRG.swift +++ b/Application/Sources/Helpers/Extensions/NSArray+PlaySRG.swift @@ -9,9 +9,9 @@ import Foundation extension NSArray { /** Returns a new array which is the receiver with objects from the specified array removed. - + - parameter array: The array of objects to remove from the receiver. - + - returns: A new array with objects from the specified array removed. */ @objc func arrayByRemovingObjects(in array: [AnyHashable]) -> NSArray { diff --git a/Application/Sources/Helpers/Extensions/NSSet+PlaySRG.swift b/Application/Sources/Helpers/Extensions/NSSet+PlaySRG.swift index d807227b5..17075a240 100644 --- a/Application/Sources/Helpers/Extensions/NSSet+PlaySRG.swift +++ b/Application/Sources/Helpers/Extensions/NSSet+PlaySRG.swift @@ -9,9 +9,9 @@ import Foundation extension NSSet { /** Returns a new set which is the receiver with objects from the specified set removed. - + - parameter set: The set of objects to remove from the receiver. - + - returns: A new set with objects from the specified set removed. */ @objc func setByRemovingObjects(in set: Set) -> NSSet { diff --git a/Application/Sources/Helpers/Extensions/SRGChannel+PlaySRG.swift b/Application/Sources/Helpers/Extensions/SRGChannel+PlaySRG.swift index dce6a7e72..79ce90780 100644 --- a/Application/Sources/Helpers/Extensions/SRGChannel+PlaySRG.swift +++ b/Application/Sources/Helpers/Extensions/SRGChannel+PlaySRG.swift @@ -9,9 +9,9 @@ import SRGDataProviderModel extension SRGChannel { var play_largeLogoImage: UIImage? { if transmission == .radio { - return RadioChannelLargeLogoImage(ApplicationConfiguration.shared.radioChannel(forUid: uid)) + RadioChannelLargeLogoImage(ApplicationConfiguration.shared.radioChannel(forUid: uid)) } else { - return TVChannelLargeLogoImage(ApplicationConfiguration.shared.tvChannel(forUid: uid)) + TVChannelLargeLogoImage(ApplicationConfiguration.shared.tvChannel(forUid: uid)) } } } diff --git a/Application/Sources/Helpers/Extensions/SRGMedia+PlaySRG.swift b/Application/Sources/Helpers/Extensions/SRGMedia+PlaySRG.swift index d2928fe64..585e0e359 100644 --- a/Application/Sources/Helpers/Extensions/SRGMedia+PlaySRG.swift +++ b/Application/Sources/Helpers/Extensions/SRGMedia+PlaySRG.swift @@ -12,68 +12,68 @@ extension SRGMedia { * Return `true` iff the URN is related to a live center event. */ @objc static func PlayIsSwissTXTURN(_ mediaURN: String) -> Bool { - return mediaURN.contains(":swisstxt:") + mediaURN.contains(":swisstxt:") } - + var play_isToday: Bool { - return NSCalendar.srg_default.isDateInToday(date) + NSCalendar.srg_default.isDateInToday(date) } - + // Return a concatenation of lead and summary, iff summary not contains the lead, to avoid duplicate information. @objc var play_fullSummary: String? { if let lead, !lead.isEmpty, let summary, !summary.isEmpty, !summary.contains(lead) { - return "\(lead)\n\n\(summary)" + "\(lead)\n\n\(summary)" } else if let summary, !summary.isEmpty { - return summary + summary } else if let lead, !lead.isEmpty { - return lead + lead } else { - return nil + nil } } - + var play_summary: String? { - return leadOrSummary + leadOrSummary } - + private var leadOrSummary: String? { - return lead?.isEmpty ?? true ? summary : lead + lead?.isEmpty ?? true ? summary : lead } - + var play_areSubtitlesAvailable: Bool { - return !play_subtitleVariants.isEmpty + !play_subtitleVariants.isEmpty } - + var play_isAudioDescriptionAvailable: Bool { - return play_audioVariants.contains(where: { $0.type == .audioDescription }) + play_audioVariants.contains(where: { $0.type == .audioDescription }) } - + var play_isMultiAudioAvailable: Bool { - let locales = self.play_audioVariants.map { $0.locale } + let locales = play_audioVariants.map(\.locale) return Set(locales).count > 1 } - + @objc var play_isWebFirst: Bool { - return date > Date() && timeAvailability(at: Date()) == .available && contentType == .episode + date > Date() && timeAvailability(at: Date()) == .available && contentType == .episode } - + var play_subtitleLanguages: [String] { - return play_subtitleVariants.map({ $0.language ?? $0.locale.identifier }) + play_subtitleVariants.map { $0.language ?? $0.locale.identifier } } - + var play_audioLanguages: [String] { - return play_audioVariants.map({ $0.language ?? $0.locale.identifier }) + play_audioVariants.map { $0.language ?? $0.locale.identifier } } - + private var play_subtitleVariants: [SRGVariant] { - return subtitleVariants(for: recommendedSubtitleVariantSource) ?? [] + subtitleVariants(for: recommendedSubtitleVariantSource) ?? [] } - + private var play_audioVariants: [SRGVariant] { - return audioVariants(for: recommendedAudioVariantSource) ?? [] + audioVariants(for: recommendedAudioVariantSource) ?? [] } - + var publicationDate: Date { - return startDate ?? date + startDate ?? date } } diff --git a/Application/Sources/Helpers/Extensions/SRGProgram+PlaySRG.swift b/Application/Sources/Helpers/Extensions/SRGProgram+PlaySRG.swift index 7611c79c9..c19496e4e 100644 --- a/Application/Sources/Helpers/Extensions/SRGProgram+PlaySRG.swift +++ b/Application/Sources/Helpers/Extensions/SRGProgram+PlaySRG.swift @@ -9,17 +9,17 @@ import SRGDataProviderModel extension SRGProgram { @objc func play_containsDate(_ date: Date) -> Bool { // Avoid potential crashes if data is incorrect - let startDate = min(self.startDate, self.endDate) - let endDate = max(self.startDate, self.endDate) - + let startDate = min(startDate, endDate) + let endDate = max(self.startDate, endDate) + return DateInterval(start: startDate, end: endDate).contains(date) } - + @objc func play_accessibilityLabel(with channel: SRGChannel?) -> String { - var label = String(format: PlaySRGAccessibilityLocalizedString("From %1$@ to %2$@", comment: "Text providing program time information. First placeholder is the start time, second is the end time."), PlayAccessibilityTimeFromDate(self.startDate), PlayAccessibilityTimeFromDate(self.endDate)) - if let channel = channel { + var label = String(format: PlaySRGAccessibilityLocalizedString("From %1$@ to %2$@", comment: "Text providing program time information. First placeholder is the start time, second is the end time."), PlayAccessibilityTimeFromDate(startDate), PlayAccessibilityTimeFromDate(endDate)) + if let channel { label += " " + String(format: PlaySRGAccessibilityLocalizedString("on %@", comment: "Text providing a channel information. Placeholder is the channel on which it's broadcasted."), channel.title) } - return label + ", " + self.title + return label + ", " + title } } diff --git a/Application/Sources/Helpers/Extensions/SRGProgramComposition+PlaySRG.swift b/Application/Sources/Helpers/Extensions/SRGProgramComposition+PlaySRG.swift index 6c548321f..7dd84490d 100644 --- a/Application/Sources/Helpers/Extensions/SRGProgramComposition+PlaySRG.swift +++ b/Application/Sources/Helpers/Extensions/SRGProgramComposition+PlaySRG.swift @@ -11,29 +11,29 @@ extension SRGProgramComposition { * Return the program at the specified date, if any. */ @objc func play_program(at date: Date) -> SRGProgram? { - return programs?.first(where: { $0.play_containsDate(date) }) + programs?.first(where: { $0.play_containsDate(date) }) } - + /** * Returns only programs matching in a given date range. The range can be open or possibly half-open. If media URNs * are provided, only matching programs will be returned. */ @objc func play_programs(from fromDate: Date?, to toDate: Date?, withMediaURNs mediaURNs: [String]?) -> [SRGProgram] { - return programs?.filter({ program in + programs?.filter { program in if let fromDate, program.startDate < fromDate { return false } - + if let toDate, toDate < program.startDate { return false } - + if let mediaURNs { guard let mediaUrn = program.mediaURN else { return false } return mediaURNs.contains(mediaUrn) } - + return true - }) ?? [] + } ?? [] } } diff --git a/Application/Sources/Helpers/Extensions/SRGShow+PlaySRG.swift b/Application/Sources/Helpers/Extensions/SRGShow+PlaySRG.swift index 1bf2cd9d1..cb4bcbaa6 100644 --- a/Application/Sources/Helpers/Extensions/SRGShow+PlaySRG.swift +++ b/Application/Sources/Helpers/Extensions/SRGShow+PlaySRG.swift @@ -7,15 +7,26 @@ import SRGDataProviderModel extension SRGShow { + @objc var play_contentType: ContentType { + switch transmission { + case .TV: + .videoOrTV + case .radio: + .audioOrRadio + default: + .mixed + } + } + var play_summary: String? { - return ApplicationConfiguration.shared.isShowLeadPreferred ? leadOrSummary : summaryOrLead + ApplicationConfiguration.shared.isShowLeadPreferred ? leadOrSummary : summaryOrLead } - + private var leadOrSummary: String? { - return lead?.isEmpty ?? true ? summary : lead + lead?.isEmpty ?? true ? summary : lead } - + private var summaryOrLead: String? { - return summary?.isEmpty ?? true ? lead : summary + summary?.isEmpty ?? true ? lead : summary } } diff --git a/Application/Sources/Helpers/Extensions/UIColor+PlaySRG.swift b/Application/Sources/Helpers/Extensions/UIColor+PlaySRG.swift index 640d10e62..a841fac72 100644 --- a/Application/Sources/Helpers/Extensions/UIColor+PlaySRG.swift +++ b/Application/Sources/Helpers/Extensions/UIColor+PlaySRG.swift @@ -8,30 +8,30 @@ import SRGAppearance extension UIColor { @objc static var play_black80a: UIColor { - return .black.withAlphaComponent(0.8) + .black.withAlphaComponent(0.8) } - + @objc static var play_notificationRed: UIColor { - return play_hexadecimal("#ed3323") + play_hexadecimal("#ed3323") } - + @objc static var play_orange: UIColor { - return play_hexadecimal("#df5200") + play_hexadecimal("#df5200") } - + static var play_popoverGrayBackground: UIColor { if UIDevice.current.userInterfaceIdiom == .pad { - return play_hexadecimal("#2d2d2d") + play_hexadecimal("#2d2d2d") } else { - return play_hexadecimal("#1a1a1a") + play_hexadecimal("#1a1a1a") } } - + @objc static var play_blackDurationLabelBackground: UIColor { - return UIColor(white: 0.0, alpha: 0.5) + UIColor(white: 0.0, alpha: 0.5) } - + private static func play_hexadecimal(_ string: String) -> UIColor { - return UIColor.hexadecimal(string) ?? UIColor.white + UIColor.hexadecimal(string) ?? UIColor.white } } diff --git a/Application/Sources/Helpers/Extensions/UIImage+PlaySRG.swift b/Application/Sources/Helpers/Extensions/UIImage+PlaySRG.swift index 7cc33d358..bff38a48d 100644 --- a/Application/Sources/Helpers/Extensions/UIImage+PlaySRG.swift +++ b/Application/Sources/Helpers/Extensions/UIImage+PlaySRG.swift @@ -14,29 +14,29 @@ extension UIImage { @objc static func image(for youthProtectionColor: SRGYouthProtectionColor) -> UIImage? { switch youthProtectionColor { case .yellow: - return UIImage(resource: .youthProtectionYellow) + UIImage(resource: .youthProtectionYellow) case .red: - return UIImage(resource: .youthProtectionRed) + UIImage(resource: .youthProtectionRed) default: - return nil + nil } } - + /** * Return the standard image to be used for a given blocking reason, if any. */ static func image(for blockingReason: SRGBlockingReason) -> UIImage? { switch blockingReason { case .geoblocking: - return UIImage(resource: .geoblocked) + UIImage(resource: .geoblocked) case .legal: - return UIImage(resource: .legal) + UIImage(resource: .legal) case .ageRating12, .ageRating18: - return UIImage(resource: .ageRating) + UIImage(resource: .ageRating) case .startDate, .endDate, .none: - return nil + nil default: - return UIImage(resource: .genericBlocked) + UIImage(resource: .genericBlocked) } } } diff --git a/Application/Sources/Helpers/Extensions/UIStackView+PlaySRG.swift b/Application/Sources/Helpers/Extensions/UIStackView+PlaySRG.swift index 87c69ff6d..d416377a3 100644 --- a/Application/Sources/Helpers/Extensions/UIStackView+PlaySRG.swift +++ b/Application/Sources/Helpers/Extensions/UIStackView+PlaySRG.swift @@ -10,18 +10,18 @@ extension UIStackView { /** Set the stack and all its arranged subviews as hidden or visible. The state of the views is not preserved for later restoration. - + This avoids constraints breaking because a stack with item spacing is hidden within another stack (its width or height is then set to 0 and, if some items in it cannot be resized, margins will create constraint conflicts). - + Also see http://stackoverflow.com/questions/33073127/nested-uistackviews-broken-constraints - + - Parameter hidden: A Boolean value that determines whether the stack and all its arranged subviews are hidden or visible. */ @objc func play_setHidden(_ hidden: Bool) { - self.isHidden = hidden - - self.arrangedSubviews.forEach { subview in + isHidden = hidden + + for subview in arrangedSubviews { subview.isHidden = hidden } } diff --git a/Application/Sources/Helpers/Extensions/UIWindow+PlaySRG.swift b/Application/Sources/Helpers/Extensions/UIWindow+PlaySRG.swift index ec9c83855..3d7b3278f 100644 --- a/Application/Sources/Helpers/Extensions/UIWindow+PlaySRG.swift +++ b/Application/Sources/Helpers/Extensions/UIWindow+PlaySRG.swift @@ -8,24 +8,24 @@ import UIKit extension UIWindow { var isLandscape: Bool { -#if os(iOS) - return self.bounds.width > self.bounds.height -#else - return true -#endif + #if os(iOS) + return bounds.width > bounds.height + #else + return true + #endif } - + /** * Return the topmost view controller (either root view controller or presented modally) */ @objc var play_topViewController: UIViewController? { - return self.rootViewController?.play_top + rootViewController?.play_top } - + /** * Dismiss all presented view controllers. */ @objc func play_dismissAllViewControllers(animated: Bool, completion: (() -> Void)? = nil) { - self.rootViewController?.dismiss(animated: animated, completion: completion) + rootViewController?.dismiss(animated: animated, completion: completion) } } diff --git a/Application/Sources/Helpers/PresenterMode.swift b/Application/Sources/Helpers/PresenterMode.swift index a53e01a43..ea58266b0 100644 --- a/Application/Sources/Helpers/PresenterMode.swift +++ b/Application/Sources/Helpers/PresenterMode.swift @@ -5,15 +5,15 @@ // #if DEBUG || NIGHTLY || BETA -import ShowTime + import ShowTime #endif import SRGLetterbox final class PresenterMode: NSObject { @objc static func enable(_ enabled: Bool) { SRGLetterboxService.shared.isMirroredOnExternalScreen = enabled -#if DEBUG || NIGHTLY || BETA - ShowTime.enabled = enabled ? .always : .never -#endif + #if DEBUG || NIGHTLY || BETA + ShowTime.enabled = enabled ? .always : .never + #endif } } diff --git a/Application/Sources/Helpers/ProgramAndChannel.swift b/Application/Sources/Helpers/ProgramAndChannel.swift index 825f1cbb5..ee6147b98 100644 --- a/Application/Sources/Helpers/ProgramAndChannel.swift +++ b/Application/Sources/Helpers/ProgramAndChannel.swift @@ -9,17 +9,16 @@ import SRGDataProviderModel struct ProgramAndChannel: Hashable { let program: SRGProgram let channel: PlayChannel - + func programGuideImageUrl(size: SRGImageSize) -> URL? { if let image = program.image { - return PlaySRG.url(for: image, size: size) + PlaySRG.url(for: image, size: size) } // Couldn't use channel image in Play SRG image service. Use raw image. else if let channelRawImage = channel.wrappedValue.rawImage { - return PlaySRG.url(for: channelRawImage, size: size) - } - else { - return nil + PlaySRG.url(for: channelRawImage, size: size) + } else { + nil } } } diff --git a/Application/Sources/Helpers/PushService.m b/Application/Sources/Helpers/PushService.m index 3870b1e2e..26bb8a20b 100755 --- a/Application/Sources/Helpers/PushService.m +++ b/Application/Sources/Helpers/PushService.m @@ -291,10 +291,10 @@ - (void)receivedNotificationResponse:(UNNotificationResponse *)notificationRespo SceneDelegate *sceneDelegate = UIApplication.sharedApplication.mainSceneDelegate; [sceneDelegate openMediaWithURN:mediaURN startTime:startTime channelUid:channelUid fromPushNotification:YES completionBlock:^{ [[AnalyticsEventObjC notificationWithAction:AnalyticsNotificationActionPlayMedia - from:AnalyticsNotificationFromOperatingSystem - uid:mediaURN - overrideSource:userInfo[@"show"] - overrideType:userInfo[@"type"]] + from:AnalyticsNotificationFromOperatingSystem + uid:mediaURN + overrideSource:userInfo[@"show"] + overrideType:userInfo[@"type"]] send]; }]; } @@ -303,19 +303,19 @@ - (void)receivedNotificationResponse:(UNNotificationResponse *)notificationRespo SceneDelegate *sceneDelegate = UIApplication.sharedApplication.mainSceneDelegate; [sceneDelegate openShowWithURN:showURN channelUid:channelUid fromPushNotification:YES completionBlock:^{ [[AnalyticsEventObjC notificationWithAction:AnalyticsNotificationActionDisplayShow - from:AnalyticsNotificationFromOperatingSystem - uid:showURN - overrideSource:nil - overrideType:userInfo[@"type"]] + from:AnalyticsNotificationFromOperatingSystem + uid:showURN + overrideSource:nil + overrideType:userInfo[@"type"]] send]; }]; } else { [[AnalyticsEventObjC notificationWithAction:AnalyticsNotificationActionAlert - from:AnalyticsNotificationFromOperatingSystem - uid:notificationContent.body - overrideSource:nil - overrideType:userInfo[@"type"]] + from:AnalyticsNotificationFromOperatingSystem + uid:notificationContent.body + overrideSource:nil + overrideType:userInfo[@"type"]] send]; } completionHandler(); diff --git a/Application/Sources/Helpers/RemoteCommandCenter.swift b/Application/Sources/Helpers/RemoteCommandCenter.swift index 24253293f..6655072cc 100644 --- a/Application/Sources/Helpers/RemoteCommandCenter.swift +++ b/Application/Sources/Helpers/RemoteCommandCenter.swift @@ -10,7 +10,7 @@ import MediaPlayer // Magic subscription handler to presumably make iOS keep the player alive so we can continuously play audio in the background. final class RemoteCommandCenter: NSObject { @objc static func activateRatingCommand() { - MPRemoteCommandCenter.shared().ratingCommand.addTarget(self, action: #selector(Self.doNothing)) + MPRemoteCommandCenter.shared().ratingCommand.addTarget(self, action: #selector(doNothing)) } @objc private static func doNothing() -> MPRemoteCommandHandlerStatus { diff --git a/Application/Sources/Helpers/SharingItem.m b/Application/Sources/Helpers/SharingItem.m index d06c4253b..b2bc6ed78 100755 --- a/Application/Sources/Helpers/SharingItem.m +++ b/Application/Sources/Helpers/SharingItem.m @@ -170,10 +170,10 @@ - (instancetype)initWithSharingItem:(SharingItem *)sharingItem } [[AnalyticsEventObjC sharingWithAction:sharingItem.analyticsAction - uid:sharingItem.analyticsUid - mediaContentType:sharingItem.mediaContentType - source:SharingItemSourceFrom(sharingItemFrom) - type:activityType] send]; + uid:sharingItem.analyticsUid + mediaContentType:sharingItem.mediaContentType + source:SharingItemSourceFrom(sharingItemFrom) + type:activityType] send]; if ([activityType isEqualToString:UIActivityTypeCopyToPasteboard]) { [Banner showWith:BannerStyleInfo diff --git a/Application/Sources/Helpers/Signals.swift b/Application/Sources/Helpers/Signals.swift index febc85bdf..0b0d8788e 100644 --- a/Application/Sources/Helpers/Signals.swift +++ b/Application/Sources/Helpers/Signals.swift @@ -40,13 +40,12 @@ enum ThrottledSignal { * Emits a signal when the history is updated for some uid or, if omitted, when any history update occurs. */ static func historyUpdates(for uid: String? = nil, interval: TimeInterval = 10) -> AnyPublisher { - return NotificationCenter.default.weakPublisher(for: .SRGHistoryEntriesDidChange, object: SRGUserData.current?.history) + NotificationCenter.default.weakPublisher(for: .SRGHistoryEntriesDidChange, object: SRGUserData.current?.history) .filter { notification in guard let uid else { return true } if let updatedUids = notification.userInfo?[SRGHistoryEntriesUidsKey] as? Set, updatedUids.contains(uid) { return true - } - else { + } else { return false } } @@ -54,23 +53,21 @@ enum ThrottledSignal { .map { _ in } .eraseToAnyPublisher() } - + /** * Emits a signal when the watch later playlist is updated for some uid or, if omitted, when any watch later update occurs. */ static func watchLaterUpdates(for uid: String? = nil, interval: TimeInterval = 10) -> AnyPublisher { - return NotificationCenter.default.weakPublisher(for: .SRGPlaylistEntriesDidChange, object: SRGUserData.current?.playlists) + NotificationCenter.default.weakPublisher(for: .SRGPlaylistEntriesDidChange, object: SRGUserData.current?.playlists) .filter { notification in if let playlistUid = notification.userInfo?[SRGPlaylistUidKey] as? String, playlistUid == SRGPlaylistUid.watchLater.rawValue { guard let uid else { return true } if let updatedUids = notification.userInfo?[SRGPlaylistEntriesUidsKey] as? Set, updatedUids.contains(uid) { return true - } - else { + } else { return false } - } - else { + } else { return false } } @@ -78,36 +75,35 @@ enum ThrottledSignal { .map { _ in } .eraseToAnyPublisher() } - + /** * Emits a signal when the user preferences are updated. */ static func preferenceUpdates(interval: TimeInterval = 10) -> AnyPublisher { - return NotificationCenter.default.weakPublisher(for: .SRGPreferencesDidChange, object: SRGUserData.current?.preferences) + NotificationCenter.default.weakPublisher(for: .SRGPreferencesDidChange, object: SRGUserData.current?.preferences) .filter { notification in if let domains = notification.userInfo?[SRGPreferencesDomainsKey] as? Set, domains.contains(PlayPreferencesDomain) { - return true - } - else { - return false + true + } else { + false } } .throttle(for: .seconds(interval), scheduler: DispatchQueue.main, latest: true) .map { _ in } .eraseToAnyPublisher() } - -#if os(iOS) - /** - * Emits a signal when downloads are updated. - */ - static func downloadUpdates(interval: TimeInterval = 10) -> AnyPublisher { - return NotificationCenter.default.weakPublisher(for: .DownloadStateDidChange, object: nil) - .throttle(for: .seconds(interval), scheduler: DispatchQueue.main, latest: true) - .map { _ in } - .eraseToAnyPublisher() - } -#endif + + #if os(iOS) + /** + * Emits a signal when downloads are updated. + */ + static func downloadUpdates(interval: TimeInterval = 10) -> AnyPublisher { + NotificationCenter.default.weakPublisher(for: .DownloadStateDidChange, object: nil) + .throttle(for: .seconds(interval), scheduler: DispatchQueue.main, latest: true) + .map { _ in } + .eraseToAnyPublisher() + } + #endif } // MARK: Signals for application events @@ -116,49 +112,49 @@ enum ApplicationSignal { enum NotificationType { case application case scene(filter: (Notification) -> Bool) - + fileprivate var foregroundNotificationName: NSNotification.Name { switch self { case .application: - return UIApplication.willEnterForegroundNotification + UIApplication.willEnterForegroundNotification case .scene: - return UIScene.willEnterForegroundNotification + UIScene.willEnterForegroundNotification } } - + fileprivate var backgroundNotificationName: NSNotification.Name { switch self { case .application: - return UIApplication.didEnterBackgroundNotification + UIApplication.didEnterBackgroundNotification case .scene: - return UIScene.didEnterBackgroundNotification + UIScene.didEnterBackgroundNotification } } - + fileprivate func filter(notification: Notification) -> Bool { switch self { case .application: - return true + true case let .scene(filter: filter): - return filter(notification) + filter(notification) } } } - + /** * Emits a signal when the application (or scene) is woken up (network reachable again or will move to the foreground). */ static func wokenUp(_ type: NotificationType = .application) -> AnyPublisher { - return Publishers.Merge(reachable(), foreground(type)) + Publishers.Merge(reachable(), foreground(type)) .throttle(for: 0.5, scheduler: DispatchQueue.main, latest: true) .eraseToAnyPublisher() } - + /** * Emits a signal when the application (or scene) will move to the foreground after some time in background. */ static func foregroundAfterTimeInBackground(_ type: NotificationType = .application) -> AnyPublisher { - return Publishers.Zip( + Publishers.Zip( background(type) .map { _ in Date() }, foreground(type) @@ -172,86 +168,86 @@ enum ApplicationSignal { .map { _ in } .eraseToAnyPublisher() } - + /** * Emits a signal when the application (or scene) will move to the foreground. */ static func foreground(_ type: NotificationType = .application) -> AnyPublisher { - return NotificationCenter.default.weakPublisher(for: type.foregroundNotificationName) + NotificationCenter.default.weakPublisher(for: type.foregroundNotificationName) .filter { type.filter(notification: $0) } .map { _ in } .eraseToAnyPublisher() } - + /** * Emits a signal when the application (or scene) moved to the background. */ static func background(_ type: NotificationType = .application) -> AnyPublisher { - return NotificationCenter.default.weakPublisher(for: type.backgroundNotificationName) + NotificationCenter.default.weakPublisher(for: type.backgroundNotificationName) .filter { type.filter(notification: $0) } .map { _ in } .eraseToAnyPublisher() } - + /** * Emits a signal when the network is reachable again. */ static func reachable() -> AnyPublisher { - return NotificationCenter.default.weakPublisher(for: .FXReachabilityStatusDidChange) + NotificationCenter.default.weakPublisher(for: .FXReachabilityStatusDidChange) .filter { ReachabilityBecameReachable($0) } .map { _ in } .eraseToAnyPublisher() } - + /// Can be used on all platforms to minimize preprocessor need, but never emits on platforms not supporting /// push notifications static func pushServiceStatusUpdate() -> AnyPublisher { -#if os(iOS) - return NotificationCenter.default.weakPublisher(for: .PushServiceStatusDidChange) - .map { _ in } - .eraseToAnyPublisher() -#else - return Empty(completeImmediately: false) - .eraseToAnyPublisher() -#endif + #if os(iOS) + return NotificationCenter.default.weakPublisher(for: .PushServiceStatusDidChange) + .map { _ in } + .eraseToAnyPublisher() + #else + return Empty(completeImmediately: false) + .eraseToAnyPublisher() + #endif } - -#if os(iOS) - static func isPushServiceBadgeDisplayed() -> AnyPublisher { - return NotificationCenter.default.weakPublisher(for: .PushServiceBadgeDidChange) - .map { _ in - return UIApplication.shared.applicationIconBadgeNumber != 0 - } - .prepend(UIApplication.shared.applicationIconBadgeNumber != 0) - .eraseToAnyPublisher() - } - - static func hasUserUnreadNotifications() -> AnyPublisher { - return NotificationCenter.default.weakPublisher(for: .UserNotificationsDidChange) - .map { _ in - return !UserNotification.unreadNotifications.isEmpty - } - .prepend(!UserNotification.unreadNotifications.isEmpty) - .eraseToAnyPublisher() - } -#endif - + + #if os(iOS) + static func isPushServiceBadgeDisplayed() -> AnyPublisher { + NotificationCenter.default.weakPublisher(for: .PushServiceBadgeDidChange) + .map { _ in + UIApplication.shared.applicationIconBadgeNumber != 0 + } + .prepend(UIApplication.shared.applicationIconBadgeNumber != 0) + .eraseToAnyPublisher() + } + + static func hasUserUnreadNotifications() -> AnyPublisher { + NotificationCenter.default.weakPublisher(for: .UserNotificationsDidChange) + .map { _ in + !UserNotification.unreadNotifications.isEmpty + } + .prepend(!UserNotification.unreadNotifications.isEmpty) + .eraseToAnyPublisher() + } + #endif + /** * Emits a signal when the user default setting at the specified key path changes. The key path must bear * the exact same name as the setting key. Key paths should be defined in `UserDefaults+ApplicationSettings.swift`. */ - static func settingUpdates(at keyPath: KeyPath) -> AnyPublisher { - return UserDefaults.standard.publisher(for: keyPath) + static func settingUpdates(at keyPath: KeyPath) -> AnyPublisher { + UserDefaults.standard.publisher(for: keyPath) .dropFirst() .map { _ in } .eraseToAnyPublisher() } - + /** * Emits a signal when the application configuration is updated. */ static func applicationConfigurationUpdate() -> AnyPublisher { - return NotificationCenter.default.weakPublisher(for: NSNotification.Name.ApplicationConfigurationDidChange) + NotificationCenter.default.weakPublisher(for: NSNotification.Name.ApplicationConfigurationDidChange) .map { _ in } .eraseToAnyPublisher() } @@ -263,14 +259,14 @@ enum ApplicationSignal { * Internal notifications sent to signal item updates resulting from user interaction. */ private extension Notification.Name { -#if os(iOS) - static let didUpdateDownloads = Notification.Name("UserInteractionDidUpdateDownloadsNotification") -#endif + #if os(iOS) + static let didUpdateDownloads = Notification.Name("UserInteractionDidUpdateDownloadsNotification") + #endif static let didUpdateFavorites = Notification.Name("UserInteractionDidUpdateFavoritesNotification") static let didUpdateHistoryEntries = Notification.Name("UserInteractionDidUpdateHistoryEntriesNotification") -#if os(iOS) - static let didUpdateNotifications = Notification.Name("UserInteractionDidUpdateNotificationsNotification") -#endif + #if os(iOS) + static let didUpdateNotifications = Notification.Name("UserInteractionDidUpdateNotificationsNotification") + #endif static let didUpdateWatchLaterEntries = Notification.Name("UserInteractionDidUpdateWatchLaterEntriesNotification") } @@ -284,50 +280,48 @@ private enum UserInteractionUpdateKey { enum UserInteractionSignal { private static func consolidate(items: [Content.Item], with notification: Notification) -> [Content.Item] { if let addedItems = notification.userInfo?[UserInteractionUpdateKey.removedItems] as? [Content.Item] { - return Array(Set(items).union(addedItems)) + Array(Set(items).union(addedItems)) + } else if let removedItems = notification.userInfo?[UserInteractionUpdateKey.addedItems] as? [Content.Item] { + Array(Set(items).subtracting(removedItems)) + } else { + items } - else if let removedItems = notification.userInfo?[UserInteractionUpdateKey.addedItems] as? [Content.Item] { - return Array(Set(items).subtracting(removedItems)) - } - else { - return items - } - } - -#if os(iOS) - static func downloadUpdates() -> AnyPublisher<[Content.Item], Never> { - return NotificationCenter.default.weakPublisher(for: .didUpdateDownloads) - .scan([Content.Item]()) { consolidate(items: $0, with: $1) } - .removeDuplicates() - .eraseToAnyPublisher() } -#endif - + + #if os(iOS) + static func downloadUpdates() -> AnyPublisher<[Content.Item], Never> { + NotificationCenter.default.weakPublisher(for: .didUpdateDownloads) + .scan([Content.Item]()) { consolidate(items: $0, with: $1) } + .removeDuplicates() + .eraseToAnyPublisher() + } + #endif + static func favoriteUpdates() -> AnyPublisher<[Content.Item], Never> { - return NotificationCenter.default.weakPublisher(for: .didUpdateFavorites) + NotificationCenter.default.weakPublisher(for: .didUpdateFavorites) .scan([Content.Item]()) { consolidate(items: $0, with: $1) } .removeDuplicates() .eraseToAnyPublisher() } - + static func historyUpdates() -> AnyPublisher<[Content.Item], Never> { - return NotificationCenter.default.weakPublisher(for: .didUpdateHistoryEntries) - .scan([Content.Item]()) { consolidate(items: $0, with: $1) } - .removeDuplicates() - .eraseToAnyPublisher() - } - -#if os(iOS) - static func notificationUpdates() -> AnyPublisher<[Content.Item], Never> { - return NotificationCenter.default.weakPublisher(for: .didUpdateNotifications) + NotificationCenter.default.weakPublisher(for: .didUpdateHistoryEntries) .scan([Content.Item]()) { consolidate(items: $0, with: $1) } .removeDuplicates() .eraseToAnyPublisher() } -#endif - + + #if os(iOS) + static func notificationUpdates() -> AnyPublisher<[Content.Item], Never> { + NotificationCenter.default.weakPublisher(for: .didUpdateNotifications) + .scan([Content.Item]()) { consolidate(items: $0, with: $1) } + .removeDuplicates() + .eraseToAnyPublisher() + } + #endif + static func watchLaterUpdates() -> AnyPublisher<[Content.Item], Never> { - return NotificationCenter.default.weakPublisher(for: .didUpdateWatchLaterEntries) + NotificationCenter.default.weakPublisher(for: .didUpdateWatchLaterEntries) .scan([Content.Item]()) { consolidate(items: $0, with: $1) } .removeDuplicates() .eraseToAnyPublisher() @@ -345,42 +339,42 @@ enum UserInteractionSignal { ]) } -#if os(iOS) - @objc static func addToDownloads(_ downloads: [Download]) { - notify(.didUpdateDownloads, for: downloads.map { Content.Item.download($0) }, added: true) - } - - @objc static func removeFromDownloads(_ downloads: [Download]) { - notify(.didUpdateDownloads, for: downloads.map { Content.Item.download($0) }, added: false) - } -#endif - + #if os(iOS) + @objc static func addToDownloads(_ downloads: [Download]) { + notify(.didUpdateDownloads, for: downloads.map { Content.Item.download($0) }, added: true) + } + + @objc static func removeFromDownloads(_ downloads: [Download]) { + notify(.didUpdateDownloads, for: downloads.map { Content.Item.download($0) }, added: false) + } + #endif + @objc static func addToFavorites(_ shows: [SRGShow]) { notify(.didUpdateFavorites, for: shows.map { Content.Item.show($0) }, added: true) } - + @objc static func removeFromFavorites(_ shows: [SRGShow]) { notify(.didUpdateFavorites, for: shows.map { Content.Item.show($0) }, added: false) } - + @objc static func addToHistory(_ medias: [SRGMedia]) { notify(.didUpdateHistoryEntries, for: medias.map { Content.Item.media($0) }, added: true) } - + @objc static func removeFromHistory(_ medias: [SRGMedia]) { notify(.didUpdateHistoryEntries, for: medias.map { Content.Item.media($0) }, added: false) } - -#if os(iOS) - @objc static func removeFromNotifications(_ notifications: [UserNotification]) { - notify(.didUpdateNotifications, for: notifications.map { Content.Item.notification($0) }, added: false) - } -#endif - + + #if os(iOS) + @objc static func removeFromNotifications(_ notifications: [UserNotification]) { + notify(.didUpdateNotifications, for: notifications.map { Content.Item.notification($0) }, added: false) + } + #endif + @objc static func addToWatchLater(_ medias: [SRGMedia]) { notify(.didUpdateWatchLaterEntries, for: medias.map { Content.Item.media($0) }, added: true) } - + @objc static func removeFromWatchLater(_ medias: [SRGMedia]) { notify(.didUpdateWatchLaterEntries, for: medias.map { Content.Item.media($0) }, added: false) } diff --git a/Application/Sources/Helpers/StoreReview.swift b/Application/Sources/Helpers/StoreReview.swift index 10ec299e6..89785b6c3 100644 --- a/Application/Sources/Helpers/StoreReview.swift +++ b/Application/Sources/Helpers/StoreReview.swift @@ -14,19 +14,19 @@ import StoreKit * @see `SKStoreReviewController` documentation for more information. */ @objc static func requestReview() { -#if !DEBUG && !NIGHTLY && !BETA - let userDefaultsKey = "PlaySRGStoreReviewRequestCount" - let userDefaults = UserDefaults.standard - var requestCount = userDefaults.integer(forKey: userDefaultsKey) + 1 - let requestCountThreshold = 50 - - if requestCount >= requestCountThreshold, let mainWindowScene = UIApplication.shared.mainWindowScene { - SKStoreReviewController.requestReview(in: mainWindowScene) - requestCount = 0 - } - - userDefaults.set(requestCount, forKey: userDefaultsKey) - userDefaults.synchronize() -#endif + #if !DEBUG && !NIGHTLY && !BETA + let userDefaultsKey = "PlaySRGStoreReviewRequestCount" + let userDefaults = UserDefaults.standard + var requestCount = userDefaults.integer(forKey: userDefaultsKey) + 1 + let requestCountThreshold = 50 + + if requestCount >= requestCountThreshold, let mainWindowScene = UIApplication.shared.mainWindowScene { + SKStoreReviewController.requestReview(in: mainWindowScene) + requestCount = 0 + } + + userDefaults.set(requestCount, forKey: userDefaultsKey) + userDefaults.synchronize() + #endif } } diff --git a/Application/Sources/Helpers/SupportInformation.swift b/Application/Sources/Helpers/SupportInformation.swift index 943c11d66..252a31dd5 100644 --- a/Application/Sources/Helpers/SupportInformation.swift +++ b/Application/Sources/Helpers/SupportInformation.swift @@ -11,58 +11,58 @@ import UIKit @objc final class SupportInformation: NSObject { private static func status(for bool: Bool) -> String { - return bool ? "Yes" : "No" + bool ? "Yes" : "No" } - + private static var dateAndTime: String { - return DateFormatter.play_shortDateAndTime.string(from: Date()) + DateFormatter.play_shortDateAndTime.string(from: Date()) } - + private static var applicationName: String { - return Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as! String + Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as! String } - + private static var applicationIdentifier: String { - return Bundle.main.object(forInfoDictionaryKey: "CFBundleIdentifier") as! String + Bundle.main.object(forInfoDictionaryKey: "CFBundleIdentifier") as! String } - + private static var applicationVersion: String { - return Bundle.main.play_friendlyVersionNumber + Bundle.main.play_friendlyVersionNumber } - + private static var operatingSystem: String { - return UIDevice.current.systemName + UIDevice.current.systemName } - + private static var operatingSystemVersion: String { - return ProcessInfo.processInfo.operatingSystemVersionString + ProcessInfo.processInfo.operatingSystemVersionString } - + private static var model: String { - return UIDevice.current.model + UIDevice.current.model } - + private static var modelIdentifier: String { var systemInfo = utsname() uname(&systemInfo) return withUnsafePointer(to: &systemInfo.machine.0) { p in - return String(cString: p) + String(cString: p) } } - + private static var loginStatus: String { guard let identityService = SRGIdentityService.current else { return "N/A" } return status(for: identityService.isLoggedIn) } - + private static var continuousAutoplayStatus: String { - return status(for: ApplicationSettingAutoplayEnabled()) + status(for: ApplicationSettingAutoplayEnabled()) } - + private static var audioSettings: String { ApplicationSettingLastSelectedAudioLanguageCode() ?? "None" } - + private static var subtitleSettings: String { switch MACaptionAppearanceGetDisplayType(.user) { case .automatic: @@ -75,66 +75,67 @@ import UIKit return "Off" } } - + private static var subtitleAccessibilitySettings: String { guard let characteristics = MACaptionAppearanceCopyPreferredCaptioningMediaCharacteristics(.user).takeRetainedValue() as? [AVMediaCharacteristic], - !characteristics.isEmpty else { + !characteristics.isEmpty + else { return "None" } return characteristics.map(\.rawValue).joined(separator: ", ") } - + private static var subtitleAvailabilityDisplayed: String { - return status(for: UserDefaults.standard.bool(forKey: PlaySRGSettingSubtitleAvailabilityDisplayed)) + status(for: UserDefaults.standard.bool(forKey: PlaySRGSettingSubtitleAvailabilityDisplayed)) } - + private static var audioDescriptionAvailabilityDisplayed: String { - return status(for: UserDefaults.standard.bool(forKey: PlaySRGSettingAudioDescriptionAvailabilityDisplayed)) + status(for: UserDefaults.standard.bool(forKey: PlaySRGSettingAudioDescriptionAvailabilityDisplayed)) } - + private static var voiceOverEnabled: String { - return status(for: UIAccessibility.isVoiceOverRunning) - } - -#if os(iOS) - private static var backgroundVideoPlaybackStatus: String { - return status(for: ApplicationSettingBackgroundVideoPlaybackEnabled()) - } - - private static var pushNotificationStatus: String { - guard let pushService = PushService.shared else { return "N/A" } - return status(for: pushService.isEnabled) - } - - private static var airshipIdentifier: String { - guard let pushService = PushService.shared else { return "N/A" } - return pushService.airshipIdentifier ?? "None" - } - - private static var deviceToken: String { - guard let pushService = PushService.shared else { return "N/A" } - return pushService.deviceToken ?? "None" - } - - private static var subscribedShowUrns: String { - guard let pushService = PushService.shared else { return "N/A" } - let subscribedShowUrns = pushService.subscribedShowURNs - guard !subscribedShowUrns.isEmpty else { return "None" } - return pushService.subscribedShowURNs.sorted().joined(separator: ",") - } -#endif - + status(for: UIAccessibility.isVoiceOverRunning) + } + + #if os(iOS) + private static var backgroundVideoPlaybackStatus: String { + status(for: ApplicationSettingBackgroundVideoPlaybackEnabled()) + } + + private static var pushNotificationStatus: String { + guard let pushService = PushService.shared else { return "N/A" } + return status(for: pushService.isEnabled) + } + + private static var airshipIdentifier: String { + guard let pushService = PushService.shared else { return "N/A" } + return pushService.airshipIdentifier ?? "None" + } + + private static var deviceToken: String { + guard let pushService = PushService.shared else { return "N/A" } + return pushService.deviceToken ?? "None" + } + + private static var subscribedShowUrns: String { + guard let pushService = PushService.shared else { return "N/A" } + let subscribedShowUrns = pushService.subscribedShowURNs + guard !subscribedShowUrns.isEmpty else { return "None" } + return pushService.subscribedShowURNs.sorted().joined(separator: ",") + } + #endif + @objc static func generate(toMailBody: Bool = false) -> String { var components = [String]() - + if toMailBody { components.append(NSLocalizedString("Please describe the issue below:", comment: "Mail body header to declare a technical issue")) components.append(contentsOf: Array(repeating: "", count: 6)) components.append("--------------------------------------") } - + components.append("General information") - components.append( "-------------------") + components.append("-------------------") components.append("Date and time: \(dateAndTime)") components.append("App name: \(applicationName)") components.append("App identifier: \(applicationIdentifier)") @@ -144,13 +145,13 @@ import UIKit components.append("Model: \(model)") components.append("Model identifier: \(modelIdentifier)") components.append("") - + components.append("App settings") - components.append( "-------------------") + components.append("-------------------") components.append("Autoplay enabled: \(continuousAutoplayStatus)") -#if os(iOS) - components.append("Background video playback enabled: \(backgroundVideoPlaybackStatus)") -#endif + #if os(iOS) + components.append("Background video playback enabled: \(backgroundVideoPlaybackStatus)") + #endif if !ApplicationConfiguration.shared.isSubtitleAvailabilityHidden { components.append("Subtitle availability displayed: \(subtitleAvailabilityDisplayed)") } @@ -162,23 +163,23 @@ import UIKit components.append("Logged in: \(loginStatus)") } components.append("") - + components.append("Device settings") - components.append( "-------------------") + components.append("-------------------") components.append("Subtitle settings: \(subtitleSettings)") components.append("Subtitle accessibility settings: \(subtitleAccessibilitySettings)") components.append("VoiceOver enabled: \(voiceOverEnabled)") components.append("") - -#if os(iOS) - components.append("Push notification information") - components.append( "-------------------") - components.append("Push notifications enabled: \(pushNotificationStatus)") - components.append("Airship identifier: \(airshipIdentifier)") - components.append("Device push notification token: \(deviceToken)") - components.append("Subscribed URNs: \(subscribedShowUrns)") -#endif - + + #if os(iOS) + components.append("Push notification information") + components.append("-------------------") + components.append("Push notifications enabled: \(pushNotificationStatus)") + components.append("Airship identifier: \(airshipIdentifier)") + components.append("Device push notification token: \(deviceToken)") + components.append("Subscribed URNs: \(subscribedShowUrns)") + #endif + return components.joined(separator: "\n") } } diff --git a/Application/Sources/Helpers/UserConsentHelper.swift b/Application/Sources/Helpers/UserConsentHelper.swift index 8f36be04e..d78681a43 100644 --- a/Application/Sources/Helpers/UserConsentHelper.swift +++ b/Application/Sources/Helpers/UserConsentHelper.swift @@ -5,29 +5,29 @@ // #if os(iOS) -import AirshipCore + import AirshipCore #endif import SRGAppearance import Usercentrics import UsercentricsUI enum UCService: Hashable, CaseIterable { -#if os(iOS) - case airship -#endif + #if os(iOS) + case airship + #endif case appcenter case comscore case firebase case srgsnitch case tagcommander case usercentrics - + var templateId: String { switch self { -#if os(iOS) - case .airship: - return "hFLVABpNP" -#endif + #if os(iOS) + case .airship: + return "hFLVABpNP" + #endif case .appcenter: return "XB0GBAmWEQ7Spr" case .comscore: @@ -46,252 +46,251 @@ enum UCService: Hashable, CaseIterable { @objc class UserConsentHelper: NSObject { // MARK: Notification names - + @objc static let userConsentWillShowBannerNotification = Notification.Name("UserConsentWillShowBannerNotification") @objc static let userConsentDidHideBannerNotification = Notification.Name("UserConsentHideBannerNotification") @objc static let userConsentDidChangeNotification = Notification.Name("UserConsentDidChangeNotification") - + @objc static let userConsentServiceConsentsKey = "userConsentServiceConsents" - + // MARK: States - + private(set) static var isConfigured = false @objc private(set) static var isShowingBanner = false - + static func serviceConsents() -> [UsercentricsServiceConsent] { - return UsercentricsCore.shared.getConsents() + UsercentricsCore.shared.getConsents() } - + // Retain potiential collecting consent banner to be displayed as modal on top of each views. // Don't forget to call `waitCollectingConsentRelease()` when the blocking condition is over. @objc static func waitCollectingConsentRetain() { waitCollectingConsentPool += 1 } - + // Release waiting pool to allow to display collecting consent banner as modal on top of each views, if any. @objc static func waitCollectingConsentRelease() { guard waitCollectingConsentPool > 0 else { return } - + waitCollectingConsentPool -= 1 - - if waitCollectingConsentPool == 0 && shouldCollectConsent { + + if waitCollectingConsentPool == 0, shouldCollectConsent { shouldCollectConsent = false - + // Dispatch on next main thread loop, with a tiny delay. DispatchQueue.global(qos: .userInteractive).asyncAfter(deadline: .now() + DispatchTimeInterval.seconds(1)) { showFirstLayer() } } } - + @objc private(set) static var srgAnalyticsLabels = SRGAnalyticsLabels() - + private static var hasRunSetup = false - + private static var waitCollectingConsentPool: UInt = 0 private static var shouldCollectConsent = false - + private static var acceptedServiceIds: [String]? { get { - return UserDefaults.standard.stringArray(forKey: PlaySRGSettingUserConsentAcceptedServiceIds) + UserDefaults.standard.stringArray(forKey: PlaySRGSettingUserConsentAcceptedServiceIds) } set { UserDefaults.standard.set(newValue, forKey: PlaySRGSettingUserConsentAcceptedServiceIds) } } - + // MARK: Setup - + @objc static func setup() { // Skip user consent banner if making screenshots guard !ProcessInfo.processInfo.arguments.contains("FL_SCREENSHOTS") else { return } - + guard !hasRunSetup else { return } - + configureAndApplyConsents() - + hasRunSetup = true } - + private static func configureAndApplyConsents() { let options = UsercentricsOptions() if let ruleSetId = Bundle.main.object(forInfoDictionaryKey: "UserCentricsRuleSetId") as? String { options.ruleSetId = ruleSetId - + if let defaultLanguage = ApplicationConfiguration.shared.userConsentDefaultLanguage { options.defaultLanguage = defaultLanguage } } -#if DEBUG - options.loggerLevel = .debug -#endif + #if DEBUG + options.loggerLevel = .debug + #endif UsercentricsCore.configure(options: options) - + applyConsent(with: acceptedServiceIds) - + UsercentricsCore.isReady { status in isConfigured = true - -#if DEBUG || NIGHTLY || BETA - shouldCollectConsent = status.shouldCollectConsent || UserDefaults.standard.bool(forKey: PlaySRGSettingAlwaysAskUserConsentAtLaunchEnabled) -#else - shouldCollectConsent = status.shouldCollectConsent -#endif - if shouldCollectConsent && waitCollectingConsentPool == 0 { + + #if DEBUG || NIGHTLY || BETA + shouldCollectConsent = status.shouldCollectConsent || UserDefaults.standard.bool(forKey: PlaySRGSettingAlwaysAskUserConsentAtLaunchEnabled) + #else + shouldCollectConsent = status.shouldCollectConsent + #endif + if shouldCollectConsent, waitCollectingConsentPool == 0 { shouldCollectConsent = false showFirstLayer() - } - else { + } else { applyConsent(with: UsercentricsCore.shared.getConsents()) } } onFailure: { error in PlayLogError(category: "UserCentrics", message: error.localizedDescription) } } - + // MARK: Banners - + private static func showFirstLayer() { guard let mainTopViewController = UIApplication.shared.mainTopViewController else { return } - + isShowingBanner = true NotificationCenter.default.post(name: userConsentWillShowBannerNotification, object: nil) - + banner.showFirstLayer(hostView: mainTopViewController) { response in isShowingBanner = false applyConsent(with: response.consents) NotificationCenter.default.post(name: userConsentDidHideBannerNotification, object: nil) } } - + static func showSecondLayer() { guard let mainTopViewController = UIApplication.shared.mainTopViewController else { return } - + isShowingBanner = true NotificationCenter.default.post(name: userConsentWillShowBannerNotification, object: nil) - + banner.showSecondLayer(hostView: mainTopViewController) { response in isShowingBanner = false applyConsent(with: response.consents) NotificationCenter.default.post(name: userConsentDidHideBannerNotification, object: nil) } } - + private static var banner: UsercentricsBanner { - return UsercentricsBanner(bannerSettings: bannerSettings) + UsercentricsBanner(bannerSettings: bannerSettings) } - + private static var bannerLogoImage: UIImage? { - return UIImage(named: "logo_bu_\(ApplicationConfiguration.shared.businessUnitIdentifier)") + UIImage(named: "logo_bu_\(ApplicationConfiguration.shared.businessUnitIdentifier)") } - + private static var bannerSettings: BannerSettings? { -#if os(iOS) - let backgroundColor = UIColor.srgGray23 - let foregroundColor = UIColor.white - let textColor = UIColor.srgGrayD2 - - var settings = GeneralStyleSettings() - - settings.layerBackgroundColor = backgroundColor - settings.layerBackgroundSecondaryColor = backgroundColor - settings.font = BannerFont(regularFont: SRGFont.font(.body), boldFont: SRGFont.font(.H3)) - settings.textColor = textColor - settings.linkColor = textColor - settings.links = LegalLinksSettings.hidden - settings.toggleStyleSettings = ToggleStyleSettings(activeBackgroundColor: .srgRed, - inactiveBackgroundColor: .srgGray96, - disabledBackgroundColor: .srgGray33, - activeThumbColor: .white, - inactiveThumbColor: .white, - disabledThumbColor: .srgGray96) - settings.tabColor = foregroundColor - settings.bordersColor = foregroundColor - settings.logo = bannerLogoImage - - let cmpData = UsercentricsCore.shared.getCMPData() - - let firstLayerSettings = FirstLayerStyleSettings(buttonLayout: .column(buttons: firstLayerButtonSettings(cmpData: cmpData)), - backgroundColor: backgroundColor, - cornerRadius: 8) - - let secondLayerSettings = SecondLayerStyleSettings(buttonLayout: .column(buttons: secondLayerButtonSettings(cmpData: cmpData)), - showCloseButton: nil) - - return BannerSettings(generalStyleSettings: settings, - firstLayerStyleSettings: firstLayerSettings, - secondLayerStyleSettings: secondLayerSettings, - variantName: nil) -#else - guard let logoImage = bannerLogoImage else { return nil } - return BannerSettings(logo: logoImage) -#endif + #if os(iOS) + let backgroundColor = UIColor.srgGray23 + let foregroundColor = UIColor.white + let textColor = UIColor.srgGrayD2 + + var settings = GeneralStyleSettings() + + settings.layerBackgroundColor = backgroundColor + settings.layerBackgroundSecondaryColor = backgroundColor + settings.font = BannerFont(regularFont: SRGFont.font(.body), boldFont: SRGFont.font(.H3)) + settings.textColor = textColor + settings.linkColor = textColor + settings.links = LegalLinksSettings.hidden + settings.toggleStyleSettings = ToggleStyleSettings(activeBackgroundColor: .srgRed, + inactiveBackgroundColor: .srgGray96, + disabledBackgroundColor: .srgGray33, + activeThumbColor: .white, + inactiveThumbColor: .white, + disabledThumbColor: .srgGray96) + settings.tabColor = foregroundColor + settings.bordersColor = foregroundColor + settings.logo = bannerLogoImage + + let cmpData = UsercentricsCore.shared.getCMPData() + + let firstLayerSettings = FirstLayerStyleSettings(buttonLayout: .column(buttons: firstLayerButtonSettings(cmpData: cmpData)), + backgroundColor: backgroundColor, + cornerRadius: 8) + + let secondLayerSettings = SecondLayerStyleSettings(buttonLayout: .column(buttons: secondLayerButtonSettings(cmpData: cmpData)), + showCloseButton: nil) + + return BannerSettings(generalStyleSettings: settings, + firstLayerStyleSettings: firstLayerSettings, + secondLayerStyleSettings: secondLayerSettings, + variantName: nil) + #else + guard let logoImage = bannerLogoImage else { return nil } + return BannerSettings(logo: logoImage) + #endif } - -#if os(iOS) - private static func firstLayerButtonSettings(cmpData: UsercentricsCMPData) -> [ButtonSettings] { - var buttons = [ButtonSettings]() - buttons.append(button(type: .acceptAll, isPrimary: true)) - if !(cmpData.settings.firstLayer?.hideButtonDeny?.boolValue ?? false) { - buttons.append(button(type: .denyAll, isPrimary: true)) + + #if os(iOS) + private static func firstLayerButtonSettings(cmpData: UsercentricsCMPData) -> [ButtonSettings] { + var buttons = [ButtonSettings]() + buttons.append(button(type: .acceptAll, isPrimary: true)) + if !(cmpData.settings.firstLayer?.hideButtonDeny?.boolValue ?? false) { + buttons.append(button(type: .denyAll, isPrimary: true)) + } + buttons.append(button(type: .more, isPrimary: false)) + return buttons } - buttons.append(button(type: .more, isPrimary: false)) - return buttons - } - - private static func secondLayerButtonSettings(cmpData: UsercentricsCMPData) -> [ButtonSettings] { - var buttons = [ButtonSettings]() - buttons.append(button(type: .acceptAll, isPrimary: false)) - if !(cmpData.settings.secondLayer.hideButtonDeny?.boolValue ?? false) { - buttons.append(button(type: .denyAll, isPrimary: false)) + + private static func secondLayerButtonSettings(cmpData: UsercentricsCMPData) -> [ButtonSettings] { + var buttons = [ButtonSettings]() + buttons.append(button(type: .acceptAll, isPrimary: false)) + if !(cmpData.settings.secondLayer.hideButtonDeny?.boolValue ?? false) { + buttons.append(button(type: .denyAll, isPrimary: false)) + } + buttons.append(button(type: .save, isPrimary: true)) + return buttons } - buttons.append(button(type: .save, isPrimary: true)) - return buttons - } - - private static func button(type: UsercentricsUI.ButtonType, isPrimary: Bool) -> ButtonSettings { - return ButtonSettings(type: type, - textColor: isPrimary ? .white : .srgGray23, - backgroundColor: isPrimary ? .srgRed : .srgGrayD2, - cornerRadius: 8) - } -#endif - + + private static func button(type: UsercentricsUI.ButtonType, isPrimary: Bool) -> ButtonSettings { + ButtonSettings(type: type, + textColor: isPrimary ? .white : .srgGray23, + backgroundColor: isPrimary ? .srgRed : .srgGrayD2, + cornerRadius: 8) + } + #endif + // MARK: Apply consent - + private static func applyConsent(with serviceConsents: [UsercentricsServiceConsent]) { - applyConsent(with: serviceConsents.filter({ $0.status == true }).map({ $0.templateId })) - + applyConsent(with: serviceConsents.filter { $0.status == true }.map(\.templateId)) + NotificationCenter.default.post(name: userConsentDidChangeNotification, object: nil, userInfo: [userConsentServiceConsentsKey: serviceConsents]) -#if DEBUG - printServices() -#endif + #if DEBUG + printServices() + #endif } - + private static func applyConsent(with acceptedServices: [String]?) { acceptedServiceIds = acceptedServices - + srgAnalyticsLabels = SRGAnalyticsLabels() srgAnalyticsLabels.customInfo = ["consent_services": (acceptedServices ?? []).joined(separator: ",")] - + for service in UCService.allCases { let consentCollected = (acceptedServices != nil) - + let acceptedService = (acceptedServices ?? []).first(where: { $0 == service.templateId }) let acceptedConsent = (acceptedService != nil) - + switch service { -#if os(iOS) - case .airship: - if PushService.shared != nil { - // Airship analytics feature is disabled at launch. See `PushService.m`. - if acceptedConsent { - Airship.shared.privacyManager.enableFeatures(Features.analytics) + #if os(iOS) + case .airship: + if PushService.shared != nil { + // Airship analytics feature is disabled at launch. See `PushService.m`. + if acceptedConsent { + Airship.shared.privacyManager.enableFeatures(Features.analytics) + } else { + Airship.shared.privacyManager.disableFeatures(Features.analytics) + } } - else { - Airship.shared.privacyManager.disableFeatures(Features.analytics) - }} -#endif + #endif case .appcenter: // Only `Crashes` service is used. `Analytics` service not instantiated. // `AppCenterAnalytics` framework not imported. @@ -313,33 +312,32 @@ enum UCService: Hashable, CaseIterable { break } } - -#if DEBUG - printAcceptedServices(acceptedServices) -#endif - } - -#if DEBUG - - // MARK: Debug - - private static func printServices() { - let data = UsercentricsCore.shared.getCMPData(), - categories = data.categories, - services = data.services - - PlayLogDebug(category: "UserConsent", message: "Settings id: \(data.settings.settingsId)") - PlayLogDebug(category: "UserConsent", message: "categorySlug / label:\n\(categories.map({ "\($0.categorySlug) / \($0.label)" }).joined(separator: "\n"))") - PlayLogDebug(category: "UserConsent", message: "templateId / dataProcessor:\n\(services.map({ "\($0.templateId ?? "null") / \($0.dataProcessor ?? "null")" }).joined(separator: "\n"))") + + #if DEBUG + printAcceptedServices(acceptedServices) + #endif } - - private static func printAcceptedServices(_ acceptedServices: [String]?) { - if let acceptedServices { - PlayLogDebug(category: "UserConsent", message: "Accepted templateIds:\n\(acceptedServices.joined(separator: "\n"))") + + #if DEBUG + + // MARK: Debug + + private static func printServices() { + let data = UsercentricsCore.shared.getCMPData(), + categories = data.categories, + services = data.services + + PlayLogDebug(category: "UserConsent", message: "Settings id: \(data.settings.settingsId)") + PlayLogDebug(category: "UserConsent", message: "categorySlug / label:\n\(categories.map { "\($0.categorySlug) / \($0.label)" }.joined(separator: "\n"))") + PlayLogDebug(category: "UserConsent", message: "templateId / dataProcessor:\n\(services.map { "\($0.templateId ?? "null") / \($0.dataProcessor ?? "null")" }.joined(separator: "\n"))") } - else { - PlayLogDebug(category: "UserConsent", message: "No consent has been given yet") + + private static func printAcceptedServices(_ acceptedServices: [String]?) { + if let acceptedServices { + PlayLogDebug(category: "UserConsent", message: "Accepted templateIds:\n\(acceptedServices.joined(separator: "\n"))") + } else { + PlayLogDebug(category: "UserConsent", message: "No consent has been given yet") + } } - } -#endif + #endif } diff --git a/Application/Sources/MiniPlayer/PlayMiniPlayerView.m b/Application/Sources/MiniPlayer/PlayMiniPlayerView.m index 27e0f5c55..14829928c 100755 --- a/Application/Sources/MiniPlayer/PlayMiniPlayerView.m +++ b/Application/Sources/MiniPlayer/PlayMiniPlayerView.m @@ -382,7 +382,7 @@ - (void)playbackButton:(SRGPlaybackButton *)playbackButton didPressInState:(SRGP } if (state == SRGPlaybackButtonStatePlay && media.mediaType == SRGMediaTypeVideo && ! ApplicationSettingBackgroundVideoPlaybackEnabled() - && ! AVAudioSession.srg_isAirPlayActive && ! controller.pictureInPictureActive) { + && ! AVAudioSession.srg_isAirPlayActive && ! controller.pictureInPictureActive) { [self.play_nearestViewController play_presentMediaPlayerFromLetterboxController:controller withAirPlaySuggestions:YES fromPushNotification:NO animated:YES completion:nil]; } } diff --git a/Application/Sources/Model/FeaturedContent.swift b/Application/Sources/Model/FeaturedContent.swift index 0b15bea1a..42f669b80 100644 --- a/Application/Sources/Model/FeaturedContent.swift +++ b/Application/Sources/Model/FeaturedContent.swift @@ -9,116 +9,116 @@ import SwiftUI protocol FeaturedContent { associatedtype Content: View - + var isPlaceholder: Bool { get } - + var introduction: String? { get } var title: String? { get } var summary: String? { get } var label: String? { get } - + var accessibilityLabel: String? { get } var accessibilityHint: String? { get } - + func visualView() -> Content - -#if os(tvOS) - func action() -#endif + + #if os(tvOS) + func action() + #endif } struct FeaturedMediaContent: FeaturedContent { let media: SRGMedia? let style: FeaturedContentCell.Style let label: String? - + var isPlaceholder: Bool { - return media == nil + media == nil } - + var introduction: String? { guard let media else { return nil } return MediaDescription.subtitle(for: media, style: mediaDescriptionStyle) } - + var title: String? { guard let media else { return nil } return MediaDescription.title(for: media, style: mediaDescriptionStyle) } - + var summary: String? { guard let media else { return nil } return MediaDescription.summary(for: media) } - + var accessibilityLabel: String? { guard let media else { return nil } return MediaDescription.cellAccessibilityLabel(for: media) } - + var accessibilityHint: String? { - return PlaySRGAccessibilityLocalizedString("Plays the content.", comment: "Featured media hint") + PlaySRGAccessibilityLocalizedString("Plays the content.", comment: "Featured media hint") } - + private var mediaDescriptionStyle: MediaDescription.Style { switch style { case .show: - return .show + .show case .date: - return .date + .date } } - + func visualView() -> some View { - return MediaVisualView(media: media, size: .medium) + MediaVisualView(media: media, size: .medium) } - -#if os(tvOS) - func action() { - if let media { - navigateToMedia(media) + + #if os(tvOS) + func action() { + if let media { + navigateToMedia(media) + } } - } -#endif + #endif } struct FeaturedShowContent: FeaturedContent { let show: SRGShow? let label: String? - + var isPlaceholder: Bool { - return show == nil + show == nil } - + var introduction: String? { - return nil + nil } - + var title: String? { - return show?.title + show?.title } - + var summary: String? { - return show?.play_summary?.compacted + show?.play_summary?.compacted } - + var accessibilityLabel: String? { - return show?.title + show?.title } - + var accessibilityHint: String? { - return PlaySRGAccessibilityLocalizedString("Opens show details.", comment: "Featured show hint") + PlaySRGAccessibilityLocalizedString("Opens show details.", comment: "Featured show hint") } - + func visualView() -> some View { - return ShowVisualView(show: show, size: .medium) + ShowVisualView(show: show, size: .medium) } - -#if os(tvOS) - func action() { - if let show { - navigateToShow(show) + + #if os(tvOS) + func action() { + if let show { + navigateToShow(show) + } } - } -#endif + #endif } diff --git a/Application/Sources/Model/Highlight.swift b/Application/Sources/Model/Highlight.swift index 1891d15d9..a02d6c99d 100644 --- a/Application/Sources/Model/Highlight.swift +++ b/Application/Sources/Model/Highlight.swift @@ -12,13 +12,13 @@ struct Highlight: Hashable { let summary: String? let image: SRGImage? let imageFocalPoint: SRGFocalPoint? - + init?(from contentSection: SRGContentSection) { let presentation = contentSection.presentation guard let title = presentation.title else { return nil } self.init(title: title, summary: presentation.summary, image: presentation.image, imageFocalPoint: presentation.imageFocalPoint) } - + init(title: String, summary: String?, image: SRGImage?, imageFocalPoint: SRGFocalPoint?) { self.title = title self.summary = summary diff --git a/Application/Sources/Model/MediaSearchSettings.swift b/Application/Sources/Model/MediaSearchSettings.swift index 24d34544b..4bdc7a2f4 100644 --- a/Application/Sources/Model/MediaSearchSettings.swift +++ b/Application/Sources/Model/MediaSearchSettings.swift @@ -16,7 +16,7 @@ struct MediaSearchSettings: Equatable { case lessThanFiveMinutes case moreThanThirtyMinutes } - + enum Period { case anytime case today @@ -24,7 +24,7 @@ struct MediaSearchSettings: Equatable { case thisWeek case lastWeek } - + var aggregationsEnabled = true var suggestionsEnabled = false var showUrns = Set() @@ -52,7 +52,7 @@ extension MediaSearchSettings { settings.maximumDurationInMinutes = nil } } - + func applyPeriod(to settings: SRGMediaSearchSettings) { switch period { case .anytime: @@ -77,7 +77,7 @@ extension MediaSearchSettings { settings.toDay = SRGDay(byAddingDays: 6, months: 0, years: 0, to: firstDayOfLastWeek) } } - + var requestSettings: SRGMediaSearchSettings { let settings = SRGMediaSearchSettings() settings.aggregationsEnabled = aggregationsEnabled diff --git a/Application/Sources/Model/Mock.swift b/Application/Sources/Model/Mock.swift index 6cb069f54..c99f4a06c 100644 --- a/Application/Sources/Model/Mock.swift +++ b/Application/Sources/Model/Mock.swift @@ -11,11 +11,11 @@ enum Mock { case standard case overflow } - + static func bucket(_ kind: Bucket = .standard) -> SRGItemBucket { - return mockObject(kind.rawValue, type: SRGItemBucket.self) + mockObject(kind.rawValue, type: SRGItemBucket.self) } - + enum Channel: String { case unknown case standard @@ -23,24 +23,24 @@ enum Mock { case standardWithoutLogo case overflowWithoutLogo } - + static func channel(_ kind: Channel = .standard) -> SRGChannel { - return mockObject(kind.rawValue, type: SRGChannel.self) + mockObject(kind.rawValue, type: SRGChannel.self) } - + static func playChannel(_ kind: Channel = .standard) -> PlayChannel { - return PlayChannel(wrappedValue: mockObject(kind.rawValue, type: SRGChannel.self), external: false) + PlayChannel(wrappedValue: mockObject(kind.rawValue, type: SRGChannel.self), external: false) } - + enum ContentSection: String { case standard case overflow } - + static func contentSection(_ kind: ContentSection = .standard) -> SRGContentSection { - return mockObject(kind.rawValue, type: SRGContentSection.self) + mockObject(kind.rawValue, type: SRGContentSection.self) } - + enum FocalPoint: String { case none case left @@ -52,16 +52,16 @@ enum Mock { case bottomLeft case bottomRight } - + static func focalPoint(_ kind: FocalPoint = .none) -> SRGFocalPoint? { switch kind { case .none: - return nil + nil default: - return mockObject(kind.rawValue, type: SRGFocalPoint.self) + mockObject(kind.rawValue, type: SRGFocalPoint.self) } } - + enum Highlight { case standard case overflow @@ -69,47 +69,47 @@ enum Mock { case topLeftAligned case bottomRightAligned } - + static func highlight(_ kind: Highlight = .standard) -> PlaySRG.Highlight { switch kind { case .standard: - return PlaySRG.Highlight( + PlaySRG.Highlight( title: "Jeune et Golri - Saison 1 inédite!", summary: "Prune, stand-uppeuse jeune et golri, rencontre Francis, vieux et dépité. Elle qui devait bosser son premier spectacle s'embarque dans cette love story inattendue!", image: SRGImage(url: URL(string: "https://il.srgssr.ch/integrationlayer/2.0/image-scale-sixteen-to-nine/https://play-pac-public-production.s3.eu-central-1.amazonaws.com/images/4fe0346b-3b3b-47cf-b31a-9d4ae4e3552a.jpeg"), variant: .default), imageFocalPoint: nil ) case .overflow: - return PlaySRG.Highlight( + PlaySRG.Highlight( title: .loremIpsum, summary: .loremIpsum, image: SRGImage(url: URL(string: "https://il.srgssr.ch/integrationlayer/2.0/image-scale-sixteen-to-nine/https://play-pac-public-production.s3.eu-central-1.amazonaws.com/images/4fe0346b-3b3b-47cf-b31a-9d4ae4e3552a.jpeg"), variant: .default), imageFocalPoint: nil ) case .short: - return PlaySRG.Highlight( + PlaySRG.Highlight( title: "Title", summary: "Description", image: SRGImage(url: URL(string: "https://il.srgssr.ch/integrationlayer/2.0/image-scale-sixteen-to-nine/https://play-pac-public-production.s3.eu-central-1.amazonaws.com/images/b75b85ed-5fbd-4f1f-983b-80ac0d92764b.jpeg"), variant: .default), imageFocalPoint: nil ) case .topLeftAligned: - return PlaySRG.Highlight( + PlaySRG.Highlight( title: "Top left", summary: "Summary", image: SRGImage(url: URL(string: "https://il.srgssr.ch/integrationlayer/2.0/image-scale-sixteen-to-nine/https://play-pac-public-production.s3.eu-central-1.amazonaws.com/images/4fe0346b-3b3b-47cf-b31a-9d4ae4e3552a.jpeg"), variant: .default), - imageFocalPoint: Self.focalPoint(.topLeft) + imageFocalPoint: focalPoint(.topLeft) ) case .bottomRightAligned: - return PlaySRG.Highlight( + PlaySRG.Highlight( title: "Bottom right", summary: "Summary", image: SRGImage(url: URL(string: "https://il.srgssr.ch/integrationlayer/2.0/image-scale-sixteen-to-nine/https://play-pac-public-production.s3.eu-central-1.amazonaws.com/images/4fe0346b-3b3b-47cf-b31a-9d4ae4e3552a.jpeg"), variant: .default), - imageFocalPoint: Self.focalPoint(.bottomRight) + imageFocalPoint: focalPoint(.bottomRight) ) } } - + enum Media: String { case standard case minimal @@ -123,66 +123,66 @@ enum Mock { case nineSixteen case square } - + static func media(_ kind: Media = .standard) -> SRGMedia { - return mockObject(kind.rawValue, type: SRGMedia.self) - } - -#if os(iOS) - static func download(_ kind: Media = .standard) -> Download? { - let media = mockObject(kind.rawValue, type: SRGMedia.self) - return Download.add(for: media) - } - - enum Notification: String { - case standard - case overflow + mockObject(kind.rawValue, type: SRGMedia.self) } - - static func notification(_ kind: Notification = .standard) -> UserNotification { - mockObject(kind.rawValue, type: UserNotification.self) - } -#endif - + + #if os(iOS) + static func download(_ kind: Media = .standard) -> Download? { + let media = mockObject(kind.rawValue, type: SRGMedia.self) + return Download.add(for: media) + } + + enum Notification: String { + case standard + case overflow + } + + static func notification(_ kind: Notification = .standard) -> UserNotification { + mockObject(kind.rawValue, type: UserNotification.self) + } + #endif + enum Program: String { case standard case overflow case fallbackImageUrl } - + static func program(_ kind: Program = .standard) -> SRGProgram { - return mockObject(kind.rawValue, type: SRGProgram.self) + mockObject(kind.rawValue, type: SRGProgram.self) } - + enum Show: String { case standard case overflow case short } - + static func show(_ kind: Show = .standard) -> SRGShow { - return mockObject(kind.rawValue, type: SRGShow.self) + mockObject(kind.rawValue, type: SRGShow.self) } - + enum Page: String { case standard case overflow case short } - + static func page(_ kind: Page = .standard) -> SRGContentPage { - return mockObject(kind.rawValue, type: SRGContentPage.self) + mockObject(kind.rawValue, type: SRGContentPage.self) } - + enum Topic: String { case standard case overflow } - + static func topic(_ kind: Topic = .standard) -> SRGTopic { - return mockObject(kind.rawValue, type: SRGTopic.self) + mockObject(kind.rawValue, type: SRGTopic.self) } - + private static func mockObject(_ name: String, type: T.Type) -> T { let clazz: AnyClass = type as! AnyClass let asset = NSDataAsset(name: "\(NSStringFromClass(clazz))_\(name)")! diff --git a/Application/Sources/Model/Onboarding.swift b/Application/Sources/Model/Onboarding.swift index b2106a40b..9dbd1d230 100644 --- a/Application/Sources/Model/Onboarding.swift +++ b/Application/Sources/Model/Onboarding.swift @@ -13,12 +13,12 @@ struct Onboarding: Codable, Identifiable { return try! JSONDecoder().decode([Self].self, from: data) .filter { !ApplicationConfiguration.shared.hiddenOnboardingUids.contains($0.id) } }() - + let id: String let title: String let pages: [OnboardingPage] - + var iconName: String { - return "\(id)_icon" + "\(id)_icon" } } diff --git a/Application/Sources/Model/OnboardingPage.swift b/Application/Sources/Model/OnboardingPage.swift index fcbd90de0..e963e01c6 100644 --- a/Application/Sources/Model/OnboardingPage.swift +++ b/Application/Sources/Model/OnboardingPage.swift @@ -11,23 +11,23 @@ struct OnboardingPage: Codable, Identifiable { let id: String let title: String let text: String - + private let colorHex: String - + enum CodingKeys: String, CodingKey { case id, title, text case colorHex = "color" } - + func imageName(for onboarding: Onboarding) -> String { - return "\(onboarding.id)_\(id)" + "\(onboarding.id)_\(id)" } - + func iconName(for onboarding: Onboarding) -> String { - return "\(onboarding.id)_\(id)-small" + "\(onboarding.id)_\(id)-small" } - + var color: UIColor { - return .hexadecimal(colorHex) ?? .white + .hexadecimal(colorHex) ?? .white } } diff --git a/Application/Sources/Model/Recommendation.swift b/Application/Sources/Model/Recommendation.swift index 0c1e6a0ea..41092bd12 100644 --- a/Application/Sources/Model/Recommendation.swift +++ b/Application/Sources/Model/Recommendation.swift @@ -11,7 +11,7 @@ struct Recommendation: Codable { * The recommendation identifier. */ let recommendationId: String - + /** * The recommended URN list. * diff --git a/Application/Sources/Model/ServiceMessage.swift b/Application/Sources/Model/ServiceMessage.swift index 6f9947a35..06c551963 100644 --- a/Application/Sources/Model/ServiceMessage.swift +++ b/Application/Sources/Model/ServiceMessage.swift @@ -8,19 +8,19 @@ import Foundation struct ServiceMessage: Codable, Identifiable, Equatable { private let data: Data - + var id: String { - return data.id + data.id } - + var text: String { - return data.text + data.text } - + static func == (lhs: Self, rhs: Self) -> Bool { - return lhs.id == rhs.id + lhs.id == rhs.id } - + private struct Data: Codable { let id: String let text: String diff --git a/Application/Sources/Onboarding/OnboardingView.swift b/Application/Sources/Onboarding/OnboardingView.swift index 37b4d37c4..9e0747640 100644 --- a/Application/Sources/Onboarding/OnboardingView.swift +++ b/Application/Sources/Onboarding/OnboardingView.swift @@ -10,12 +10,12 @@ import SwiftUI struct OnboardingView: UIViewControllerRepresentable { let onboarding: Onboarding - - func makeUIViewController(context: Context) -> OnboardingViewController { - return .viewController(for: onboarding) + + func makeUIViewController(context _: Context) -> OnboardingViewController { + .viewController(for: onboarding) } - - func updateUIViewController(_ uiViewController: OnboardingViewController, context: Context) { + + func updateUIViewController(_: OnboardingViewController, context _: Context) { // Never updated } } diff --git a/Application/Sources/Onboarding/OnboardingViewController.swift b/Application/Sources/Onboarding/OnboardingViewController.swift index 971b25c92..2a27b7f1c 100755 --- a/Application/Sources/Onboarding/OnboardingViewController.swift +++ b/Application/Sources/Onboarding/OnboardingViewController.swift @@ -9,19 +9,19 @@ import SRGAppearance final class OnboardingViewController: BaseViewController { final var onboarding: Onboarding! - + private weak var paperOnboarding: PaperOnboarding! - - @IBOutlet private weak var previousButton: UIButton! - @IBOutlet private weak var closeButton: UIButton! - @IBOutlet private weak var nextButton: UIButton! - - @IBOutlet private weak var buttonBottomConstraint: NSLayoutConstraint! - + + @IBOutlet private var previousButton: UIButton! + @IBOutlet private var closeButton: UIButton! + @IBOutlet private var nextButton: UIButton! + + @IBOutlet private var buttonBottomConstraint: NSLayoutConstraint! + private var isTall: Bool { - return view.frame.height >= 600.0 + view.frame.height >= 600.0 } - + static func viewController(for onboarding: Onboarding!) -> OnboardingViewController { let storyboard = UIStoryboard(name: "OnboardingViewController", bundle: nil) let viewController = storyboard.instantiateInitialViewController() as! Self @@ -29,119 +29,117 @@ final class OnboardingViewController: BaseViewController { viewController.title = onboarding.title return viewController } - + override func viewDidLoad() { super.viewDidLoad() - + previousButton.setTitleColor(.white, for: .normal) previousButton.setTitle(NSLocalizedString("Previous", comment: "Title of the button to proceed to the previous onboarding page"), for: .normal) - + closeButton.setTitleColor(.white, for: .normal) closeButton.setTitle(NSLocalizedString("OK", comment: "Title of the button displayed at the end of an onboarding"), for: .normal) - + nextButton.setTitleColor(.white, for: .normal) nextButton.setTitle(NSLocalizedString("Next", comment: "Title of the button to proceed to the next onboarding page"), for: .normal) - + // Set tint color to white. Cannot easily customize colors on a page basis (page control current item color // cannot be customized). Force the text to be white. let paperOnboarding = PaperOnboarding() paperOnboarding.tintColor = .white - + // Set the delegate before the data source so that all delegate methods are correctly called when loading the // first page (sigh). paperOnboarding.delegate = self paperOnboarding.dataSource = self view.insertSubview(paperOnboarding, at: 0) self.paperOnboarding = paperOnboarding - + NSLayoutConstraint.activate([ paperOnboarding.topAnchor.constraint(equalTo: view.topAnchor), paperOnboarding.bottomAnchor.constraint(equalTo: view.bottomAnchor), paperOnboarding.leadingAnchor.constraint(equalTo: view.leadingAnchor), paperOnboarding.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) - + updateUserInterface(index: 0, animated: false) - + NotificationCenter.default.addObserver(self, selector: #selector(accessibilityVoiceOverStatusChanged(notification:)), name: UIAccessibility.voiceOverStatusDidChangeNotification, object: nil) } - + override var preferredStatusBarStyle: UIStatusBarStyle { - return .lightContent + .lightContent } - + override func viewWillLayoutSubviews() { super.viewWillLayoutSubviews() - + let smallFontSize = CGFloat(isTall ? 20.0 : 14.0) let largeFontSize = CGFloat(isTall ? 24.0 : 16.0) - + previousButton.titleLabel?.font = SRGFont.font(family: .text, weight: .medium, fixedSize: smallFontSize) closeButton.titleLabel?.font = SRGFont.font(family: .text, weight: .medium, fixedSize: largeFontSize) nextButton.titleLabel?.font = SRGFont.font(family: .text, weight: .medium, fixedSize: smallFontSize) - + buttonBottomConstraint.constant = 0.19 * view.frame.height } - + private func updateUserInterface(index: Int, animated: Bool) { let animations = { let isFirstPage = (index == 0) let isLastPage = (index == self.onboarding.pages.count - 1) - + self.closeButton.alpha = isLastPage ? 1.0 : 0.0 - + let voiceOverEnabled = UIAccessibility.isVoiceOverRunning self.previousButton.alpha = (voiceOverEnabled && !isFirstPage) ? 1.0 : 0.0 self.nextButton.alpha = (voiceOverEnabled && !isLastPage) ? 1.0 : 0.0 } - + if animated { UIView.animate(withDuration: 0.2, animations: animations) - } - else { + } else { animations() } } - - @IBAction private func previousPage(_ sender: UIButton) { + + @IBAction private func previousPage(_: UIButton) { paperOnboarding.currentIndex(paperOnboarding.currentIndex - 1, animated: true) } - - @IBAction private func close(_ sender: UIButton) { + + @IBAction private func close(_: UIButton) { if ["favorites", "favorites_account"].contains(onboarding.id) { PushService.shared?.presentSystemAlertForPushNotifications() } dismiss(animated: true, completion: nil) } - - @IBAction private func nextPage(_ sender: UIButton) { + + @IBAction private func nextPage(_: UIButton) { paperOnboarding.currentIndex(paperOnboarding.currentIndex + 1, animated: true) } - + // MARK: Notifications - - @objc private func accessibilityVoiceOverStatusChanged(notification: NSNotification) { + + @objc private func accessibilityVoiceOverStatusChanged(notification _: NSNotification) { updateUserInterface(index: paperOnboarding.currentIndex, animated: true) } } -extension OnboardingViewController: Oriented { -} +extension OnboardingViewController: Oriented {} extension OnboardingViewController: PaperOnboardingDataSource { func onboardingItemsCount() -> Int { - return onboarding.pages.count + onboarding.pages.count } - + func onboardingItem(at index: Int) -> OnboardingItemInfo { let page = onboarding.pages[index] - + let titleFontSize = CGFloat(isTall ? 24.0 : 20.0) let subtitleFontSize = CGFloat(isTall ? 15.0 : 14.0) - + return OnboardingItemInfo(informationImage: UIImage(named: page.imageName(for: onboarding)) ?? UIImage(), title: PlaySRGOnboardingLocalizedString(page.title, comment: nil), description: PlaySRGOnboardingLocalizedString(page.text, comment: nil), @@ -160,33 +158,33 @@ extension OnboardingViewController: PaperOnboardingDelegate { func onboardingWillTransitonToIndex(_ index: Int) { updateUserInterface(index: index, animated: true) } - + func onboardingDidTransitonToIndex(_: Int) { UIAccessibility.post(notification: UIAccessibility.Notification.screenChanged, argument: paperOnboarding) } - + func onboardingConfigurationItem(_ item: OnboardingContentViewItem, index _: Int) { item.titleLabel?.numberOfLines = 2 item.descriptionLabel?.numberOfLines = 0 - + let constant = CGFloat(isTall ? 200.0 : 120.0) item.informationImageWidthConstraint?.constant = constant item.informationImageHeightConstraint?.constant = constant - + item.titleCenterConstraint?.constant = isTall ? 50.0 : 20.0 } } extension OnboardingViewController: SRGAnalyticsViewTracking { var srg_pageViewTitle: String { - return onboarding.title + onboarding.title } - + var srg_pageViewType: String { - return AnalyticsPageType.help.rawValue + AnalyticsPageType.help.rawValue } - + var srg_pageViewLevels: [String]? { - return [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.application.rawValue, AnalyticsPageLevel.feature.rawValue] + [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.application.rawValue, AnalyticsPageLevel.feature.rawValue] } } diff --git a/Application/Sources/Player/MediaPlayerViewController+SongPanel.swift b/Application/Sources/Player/MediaPlayerViewController+SongPanel.swift index 5a8dacc67..ba8d33fea 100644 --- a/Application/Sources/Player/MediaPlayerViewController+SongPanel.swift +++ b/Application/Sources/Player/MediaPlayerViewController+SongPanel.swift @@ -13,79 +13,77 @@ private var tapGestureRecognizerKey: Void? extension MediaPlayerViewController { static let contentHeight: CGFloat = 64.0 - + @objc func addSongPanel(channel: SRGChannel) { guard let songsViewStyle = ApplicationConfiguration.shared.channel(forUid: channel.uid)?.songsViewStyle, songsViewStyle != .none else { return } - + if let contentNavigationController = panel?.contentViewController as? UINavigationController, let songsViewController = contentNavigationController.viewControllers.first as? SongsViewController { if songsViewController.channel == channel { return - } - else { + } else { removeSongPanel() } } - + if let programsTableView { let insets = UIEdgeInsets(top: 0.0, left: 0.0, bottom: MediaPlayerViewController.contentHeight, right: 0.0) programsTableView.contentInset = insets programsTableView.scrollIndicatorInsets = insets } - + let collapsed = (songsViewStyle == .collapsed) let panel = makePanelController(channel: channel, mode: collapsed ? .compact : .expanded) panel.add(to: self) self.panel = panel - + updateSongTableVisibility(hidden: collapsed, animated: false) } - + @objc func removeSongPanel() { guard let panel else { return } panel.removeFromParent(transition: .none, completion: nil) self.panel = nil - + if let programsTableView { programsTableView.contentInset = .zero programsTableView.scrollIndicatorInsets = .zero } } - + @objc func updateSongPanel(for traitCollection: UITraitCollection, fullScreen: Bool) { guard let panel else { return } - + if fullScreen { panel.removeFromParent(transition: .none, completion: nil) - } - else { + } else { panel.add(to: self) panel.performWithoutAnimation { panel.configuration = configuration(for: traitCollection, mode: panel.configuration.mode) } } } - + @objc func reloadSongPanelSize() { guard let panel else { return } panel.reloadSize() } - + @objc func scrollToSong(at date: Date?, animated: Bool) { guard let songsViewController else { return } songsViewController.scrollToSong(at: date, animated: animated) } - + @objc func updateSelectionForSong(at date: Date?) { guard let songsViewController else { return } songsViewController.updateSelectionForSong(at: date) } - + @objc func updateSelectionForCurrentSong() { guard let songsViewController else { return } songsViewController.updateSelectionForCurrentSong() } - + @objc func updateSongProgress() { guard let songsViewController else { return } songsViewController.updateProgress(for: letterboxController.play_dateInterval) @@ -95,102 +93,99 @@ extension MediaPlayerViewController { private extension MediaPlayerViewController { var panel: Panel? { get { - return objc_getAssociatedObject(self, &panelKey) as? Panel + objc_getAssociatedObject(self, &panelKey) as? Panel } set { objc_setAssociatedObject(self, &panelKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } - + var tapGestureRecognizer: UITapGestureRecognizer? { get { - return objc_getAssociatedObject(self, &tapGestureRecognizerKey) as? UITapGestureRecognizer + objc_getAssociatedObject(self, &tapGestureRecognizerKey) as? UITapGestureRecognizer } set { objc_setAssociatedObject(self, &tapGestureRecognizerKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } - + var compactHeight: CGFloat { if let window = UIApplication.shared.mainWindow { - return MediaPlayerViewController.contentHeight + window.safeAreaInsets.bottom - } - else { - return MediaPlayerViewController.contentHeight + MediaPlayerViewController.contentHeight + window.safeAreaInsets.bottom + } else { + MediaPlayerViewController.contentHeight } } - + var songsViewController: SongsViewController? { guard let panel else { return nil } guard let contentNavigationController = panel.contentViewController as? UINavigationController else { return nil } return contentNavigationController.viewControllers.first as? SongsViewController } - + func makePanelController(channel: SRGChannel, mode: Panel.Configuration.Mode) -> Panel { let songsViewController = SongsViewController(channel: channel, letterboxController: letterboxController) let contentNavigationController = NavigationController(rootViewController: songsViewController, tintColor: .white, backgroundColor: .srgGray23, statusBarStyle: .default) - + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(togglePanel(_:))) contentNavigationController.navigationBar.addGestureRecognizer(tapGestureRecognizer) self.tapGestureRecognizer = tapGestureRecognizer - - let panelController = Panel(configuration: configuration(for: self.traitCollection, mode: mode)) + + let panelController = Panel(configuration: configuration(for: traitCollection, mode: mode)) panelController.sizeDelegate = self panelController.resizeDelegate = self panelController.accessibilityDelegate = self panelController.contentViewController = contentNavigationController - + return panelController } - + func configuration(for traitCollection: UITraitCollection, mode: Panel.Configuration.Mode) -> Panel.Configuration { var configuration = Panel.Configuration.default - - if traitCollection.userInterfaceIdiom == .pad && traitCollection.horizontalSizeClass == .regular { + + if traitCollection.userInterfaceIdiom == .pad, traitCollection.horizontalSizeClass == .regular { configuration.position = .leadingBottom configuration.positionLogic[.bottom] = .respectSafeArea configuration.margins = NSDirectionalEdgeInsets(top: 0.0, leading: 10.0, bottom: 0.0, trailing: 10.0) - } - else { + } else { configuration.position = .bottom configuration.positionLogic[.bottom] = .ignoreSafeArea configuration.margins = .zero } - + configuration.supportedModes = [.compact, .expanded, .fullHeight] configuration.mode = mode - + configuration.appearance.resizeHandle = .visible(foregroundColor: .srgGrayD2, backgroundColor: .srgGray23) configuration.appearance.separatorColor = .clear configuration.appearance.borderColor = .clear - + return configuration } - + func songTableView() -> UITableView? { guard let songsViewController else { return nil } return songsViewController.tableView } - + func updateSongTableVisibility(hidden: Bool, animated: Bool) { guard let tableView = songTableView() else { return } - + let animations: () -> Void = { tableView.alpha = hidden ? 0.0 : 1.0 } - + if animated { UIView.animate(withDuration: 0.1, animations: animations) - } - else { + } else { animations() } } - - @objc func togglePanel(_ sender: UITapGestureRecognizer) { + + @objc func togglePanel(_: UITapGestureRecognizer) { guard let panel else { return } - + switch panel.configuration.mode { case .compact: panel.configuration.mode = .expanded @@ -214,8 +209,7 @@ extension MediaPlayerViewController: PanelSizeDelegate { case .expanded: if let parent = panel.parent { return CGSize(width: width, height: 0.45 * parent.view.frame.height) - } - else { + } else { return CGSize(width: width, height: 400.0) } default: @@ -226,20 +220,20 @@ extension MediaPlayerViewController: PanelSizeDelegate { } extension MediaPlayerViewController: PanelResizeDelegate { - public func panelDidStartResizing(_ panel: Panel) { + public func panelDidStartResizing(_: Panel) { // Trick to avoid the tap gesture triggered at the same time as the resizing one for small finger movements. tapGestureRecognizer?.isEnabled = false DispatchQueue.main.async { self.tapGestureRecognizer?.isEnabled = true } } - - public func panel(_ panel: Panel, willResizeTo size: CGSize) { + + public func panel(_: Panel, willResizeTo size: CGSize) { let hidden = (size.height <= compactHeight) updateSongTableVisibility(hidden: hidden, animated: true) } - - public func panel(_ panel: Panel, willTransitionFrom oldMode: Panel.Configuration.Mode?, to newMode: Panel.Configuration.Mode, with coordinator: PanelTransitionCoordinator) { + + public func panel(_: Panel, willTransitionFrom oldMode: Panel.Configuration.Mode?, to _: Panel.Configuration.Mode, with coordinator: PanelTransitionCoordinator) { if let tableView = songTableView() { coordinator.animateAlongsideTransition({ if oldMode == .compact { @@ -255,16 +249,15 @@ extension MediaPlayerViewController: PanelResizeDelegate { } extension MediaPlayerViewController: PanelAccessibilityDelegate { - public func panel(_ panel: Panel, accessibilityLabelForResizeHandle resizeHandle: ResizeHandle) -> String { + public func panel(_ panel: Panel, accessibilityLabelForResizeHandle _: ResizeHandle) -> String { if panel.configuration.mode == .compact { - return PlaySRGAccessibilityLocalizedString("Show music list", comment: "Accessibility label of the song list handle when closed") - } - else { - return PlaySRGAccessibilityLocalizedString("Hide music list", comment: "Accessibility label of the song list handle when opened") + PlaySRGAccessibilityLocalizedString("Show music list", comment: "Accessibility label of the song list handle when closed") + } else { + PlaySRGAccessibilityLocalizedString("Hide music list", comment: "Accessibility label of the song list handle when opened") } } - - public func panel(_ panel: Panel, didActivateResizeHandle resizeHandle: ResizeHandle) -> Bool { + + public func panel(_ panel: Panel, didActivateResizeHandle _: ResizeHandle) -> Bool { switch panel.configuration.mode { case .compact: panel.configuration.mode = .expanded diff --git a/Application/Sources/Player/MediaPlayerViewController.h b/Application/Sources/Player/MediaPlayerViewController.h index 38a4749c3..eba6c5f6f 100755 --- a/Application/Sources/Player/MediaPlayerViewController.h +++ b/Application/Sources/Player/MediaPlayerViewController.h @@ -13,7 +13,7 @@ NS_ASSUME_NONNULL_BEGIN @interface MediaPlayerViewController : BaseViewController +UIGestureRecognizerDelegate, UITableViewDataSource, UITableViewDelegate, UIViewControllerTransitioningDelegate, NSUserActivityDelegate> // Use nil for starting at the default location (resumes if the media is already being played) - (instancetype)initWithURN:(NSString *)URN position:(nullable SRGPosition *)position fromPushNotification:(BOOL)fromPushNotification; diff --git a/Application/Sources/Player/MediaPlayerViewController.m b/Application/Sources/Player/MediaPlayerViewController.m index ecc8afc70..7af23c80e 100755 --- a/Application/Sources/Player/MediaPlayerViewController.m +++ b/Application/Sources/Player/MediaPlayerViewController.m @@ -60,8 +60,8 @@ static const CGFloat MediaPlayerDetailsLabelExpansionThresholdFactor = 1.4f; static const CGFloat MediaPlayerDetailsLabelCollapsedHeight = 90.f; -static const UILayoutPriority MediaPlayerDetailsLabelNormalPriority = 999; // Cannot mutate priority of required installed constraints (throws an exception at runtime), so use lower priority -static const UILayoutPriority MediaPlayerDetailsLabelExpandedPriority = 300; +static const UILayoutPriority MediaPlayerViewHighLayoutPriority = 999; // Cannot mutate priority of required installed constraints (throws an exception at runtime), so use lower priority +static const UILayoutPriority MediaPlayerViewLowLayoutPriority = 300; static NSDateComponentsFormatter *MediaPlayerViewControllerSkipIntervalAccessibilityFormatter(void) { @@ -167,6 +167,9 @@ @interface MediaPlayerViewController () @property (nonatomic, weak) IBOutlet NSLayoutConstraint *availabilityLabelHeightConstraint; +@property (nonatomic, weak) IBOutlet NSLayoutConstraint *showThumbnailImageViewAspectRatio16_9Constraint; +@property (nonatomic, weak) IBOutlet NSLayoutConstraint *showThumbnailImageViewAspectRatio1_1Constraint; + // Switching to and from full-screen is made by adjusting the priority of constraints at the top and bottom of the player view @property (nonatomic, weak) IBOutlet NSLayoutConstraint *playerTopConstraint; @property (nonatomic, weak) IBOutlet NSLayoutConstraint *playerBottomConstraint; @@ -308,7 +311,7 @@ - (void)setLetterboxController:(SRGLetterboxController *)letterboxController if (letterboxController.continuousPlaybackUpcomingMedia) { [[AnalyticsEventObjC continuousPlaybackWithAction:AnalyticsContiniousPlaybackActionDisplay - mediaUrn:letterboxController.continuousPlaybackUpcomingMedia.URN] + mediaUrn:letterboxController.continuousPlaybackUpcomingMedia.URN] send]; } }]; @@ -399,7 +402,7 @@ - (void)viewDidLoad self.playerBottomConstraint.priority = MediaPlayerBottomConstraintNormalPriority; self.metadataHeightConstraint.priority = MediaPlayerBottomConstraintNormalPriority; - self.collapsedDetailsLabelsHeightConstraint.priority = MediaPlayerDetailsLabelNormalPriority; + self.collapsedDetailsLabelsHeightConstraint.priority = MediaPlayerViewHighLayoutPriority; self.livestreamButton.backgroundColor = UIColor.srg_gray23Color; self.livestreamButton.layer.cornerRadius = LayoutStandardViewCornerRadius; @@ -541,7 +544,7 @@ - (void)viewDidDisappear:(BOOL)animated if (self.letterboxController.continuousPlaybackUpcomingMedia) { [[AnalyticsEventObjC continuousPlaybackWithAction:AnalyticsContiniousPlaybackActionCancel - mediaUrn:self.letterboxController.continuousPlaybackUpcomingMedia.URN] + mediaUrn:self.letterboxController.continuousPlaybackUpcomingMedia.URN] send]; } @@ -922,7 +925,16 @@ - (void)reloadDetailsWithMedia:(SRGMedia *)media mainChapterMedia:(SRGMedia *)ma - (void)reloadDetailsWithShow:(SRGShow *)show { if (show) { - [self.showThumbnailImageView play_requestImage:show.image withSize:SRGImageSizeSmall placeholder:ImagePlaceholderMediaList]; + BOOL prefersSquareImage = show.play_contentType == ContentTypeAudioOrRadio && [ApplicationConfiguration sharedApplicationConfiguration].squareImagesEnabled; + if (prefersSquareImage) { + self.showThumbnailImageViewAspectRatio16_9Constraint.priority = MediaPlayerViewLowLayoutPriority; + self.showThumbnailImageViewAspectRatio1_1Constraint.priority = MediaPlayerViewHighLayoutPriority; + } + else { + self.showThumbnailImageViewAspectRatio1_1Constraint.priority = MediaPlayerViewLowLayoutPriority; + self.showThumbnailImageViewAspectRatio16_9Constraint.priority = MediaPlayerViewHighLayoutPriority; + } + [self.showThumbnailImageView play_requestImage:prefersSquareImage ? show.podcastImage : show.image withSize:SRGImageSizeSmall placeholder:ImagePlaceholderMediaList]; self.showLabel.font = [SRGFont fontWithStyle:SRGFontStyleH4]; self.showLabel.text = show.title; @@ -1114,13 +1126,13 @@ - (void)updateAppearanceWithDetailsExpanded:(BOOL)expanded { // Change to expanded mode (set low priority for height restriction, so that vertical content hugging dominates) if (expanded) { - self.collapsedDetailsLabelsHeightConstraint.priority = MediaPlayerDetailsLabelExpandedPriority; + self.collapsedDetailsLabelsHeightConstraint.priority = MediaPlayerViewLowLayoutPriority; self.detailsButton.transform = CGAffineTransformMakeRotation(M_PI); } // Change to collapsed mode (set high priority for height restriction) else { - self.collapsedDetailsLabelsHeightConstraint.priority = MediaPlayerDetailsLabelNormalPriority; + self.collapsedDetailsLabelsHeightConstraint.priority = MediaPlayerViewHighLayoutPriority; // Use small value so that the arrow always rotates in the inverse direction self.detailsButton.transform = CGAffineTransformMakeRotation(0.00001); @@ -1657,7 +1669,7 @@ - (void)letterboxView:(SRGLetterboxView *)letterboxView didSelectAudioLanguageCo - (void)letterboxView:(SRGLetterboxView *)letterboxView didEngageInContinuousPlaybackWithUpcomingMedia:(SRGMedia *)upcomingMedia { [[AnalyticsEventObjC continuousPlaybackWithAction:AnalyticsContiniousPlaybackActionPlay - mediaUrn:upcomingMedia.URN] + mediaUrn:upcomingMedia.URN] send]; } @@ -1680,7 +1692,7 @@ - (void)letterboxView:(SRGLetterboxView *)letterboxView didCancelContinuousPlayb }, @"DisableAutoplayAsked"); [[AnalyticsEventObjC continuousPlaybackWithAction:AnalyticsContiniousPlaybackActionCancel - mediaUrn:upcomingMedia.URN] + mediaUrn:upcomingMedia.URN] send]; } diff --git a/Application/Sources/Player/MediaPlayerViewController.storyboard b/Application/Sources/Player/MediaPlayerViewController.storyboard index 049a9789f..a31f50531 100755 --- a/Application/Sources/Player/MediaPlayerViewController.storyboard +++ b/Application/Sources/Player/MediaPlayerViewController.storyboard @@ -1,9 +1,9 @@ - + - + @@ -336,8 +336,9 @@ - + + @@ -697,6 +698,8 @@ + + diff --git a/Application/Sources/Player/SongsViewController.m b/Application/Sources/Player/SongsViewController.m index 59e9b2a23..177b2a733 100644 --- a/Application/Sources/Player/SongsViewController.m +++ b/Application/Sources/Player/SongsViewController.m @@ -58,7 +58,7 @@ - (void)loadView { UIView *view = [[UIView alloc] initWithFrame:UIScreen.mainScreen.bounds]; view.backgroundColor = UIColor.srg_gray23Color; - + TableView *tableView = [[TableView alloc] init]; tableView.dataSource = self; tableView.delegate = self; diff --git a/Application/Sources/Profile/ProfileAccountHeaderView+UIKit.swift b/Application/Sources/Profile/ProfileAccountHeaderView+UIKit.swift index c24772934..8a11e0c92 100644 --- a/Application/Sources/Profile/ProfileAccountHeaderView+UIKit.swift +++ b/Application/Sources/Profile/ProfileAccountHeaderView+UIKit.swift @@ -8,13 +8,13 @@ import UIKit extension UITableView { final class ProfileAccountTableViewHeaderView: HostView { - override func willMove(toWindow newWindow: UIWindow?) { + override func willMove(toWindow _: UIWindow?) { content = ProfileAccountHeaderView() } } - + @objc func profileAccountHeaderView() -> UIView { - return ProfileAccountTableViewHeaderView( + ProfileAccountTableViewHeaderView( frame: CGRect(origin: .zero, size: ProfileAccountHeaderView.size()), leadingAnchorConstant: LayoutMargin * 2, trailingAnchorConstant: -LayoutMargin * 2 diff --git a/Application/Sources/Profile/ProfileAccountHeaderView.swift b/Application/Sources/Profile/ProfileAccountHeaderView.swift index 852ab815c..e6c34fc1e 100644 --- a/Application/Sources/Profile/ProfileAccountHeaderView.swift +++ b/Application/Sources/Profile/ProfileAccountHeaderView.swift @@ -12,29 +12,29 @@ import SwiftUI struct ProfileAccountHeaderView: View { @StateObject private var model = ProfileAccountHeaderViewModel() - + var body: some View { MainView(model: model) } - + /// Behavior: h-exp, v-exp private struct MainView: View { @ObservedObject var model: ProfileAccountHeaderViewModel - + @Environment(\.isUIKitFocused) private var isFocused - + private let spacing: CGFloat = LayoutMargin * 1.5 private let iconHeight: CGFloat = 24 * 1.5 private let lineWidth: CGFloat = 1.2 - + private let serviceLogoHeight: CGFloat = 24 * 1.5 * 0.6 private let serviceLogoOffsetX: CGFloat = 14 private let serviceLogoOffsetY: CGFloat = -3 - + private var trailingImagePadding: CGFloat { - return UIImage(named: "identity_service_logo") != nil ? serviceLogoOffsetX : 0 + UIImage(named: "identity_service_logo") != nil ? serviceLogoOffsetX : 0 } - + var body: some View { Button { model.manageAccount() @@ -62,8 +62,7 @@ struct ProfileAccountHeaderView: View { .frame(maxWidth: iconHeight - lineWidth, maxHeight: iconHeight - lineWidth) ) .opacity(1) - } - else { + } else { Rectangle() .foregroundColor(.clear) .frame(maxWidth: iconHeight, maxHeight: iconHeight) @@ -104,7 +103,7 @@ struct ProfileAccountHeaderView: View { extension ProfileAccountHeaderView { static func size() -> CGSize { - return CGSize(width: .zero, height: 66) + CGSize(width: .zero, height: 66) } } diff --git a/Application/Sources/Profile/ProfileAccountHeaderViewModel.swift b/Application/Sources/Profile/ProfileAccountHeaderViewModel.swift index aa2c2aadb..dd97af7f8 100644 --- a/Application/Sources/Profile/ProfileAccountHeaderViewModel.swift +++ b/Application/Sources/Profile/ProfileAccountHeaderViewModel.swift @@ -11,10 +11,10 @@ import SRGIdentity final class ProfileAccountHeaderViewModel: ObservableObject { @Published var data: Data - + func manageAccount() { guard let identityService = SRGIdentityService.current else { return } - + if identityService.isLoggedIn { identityService.showAccountView() } else { @@ -24,12 +24,12 @@ final class ProfileAccountHeaderViewModel: ObservableObject { } } } - + init() { guard let identityService = SRGIdentityService.current else { data = .notLogged; return } - + data = Data(isLoggedIn: identityService.isLoggedIn, account: identityService.account) - + Publishers.Merge3( NotificationCenter.default.weakPublisher(for: .SRGIdentityServiceUserDidLogin, object: identityService), NotificationCenter.default.weakPublisher(for: .SRGIdentityServiceDidUpdateAccount, object: identityService), @@ -49,17 +49,16 @@ final class ProfileAccountHeaderViewModel: ObservableObject { extension ProfileAccountHeaderViewModel { var accessibilityLabel: String { if let accountDescription = data.accountDescription { - return String(format: PlaySRGAccessibilityLocalizedString("Logged in user: %@", comment: "Accessibility introductory text for the logged in user"), accountDescription) - } - else { - return data.text + String(format: PlaySRGAccessibilityLocalizedString("Logged in user: %@", comment: "Accessibility introductory text for the logged in user"), accountDescription) + } else { + data.text } } - + var accessibilityHint: String { - return data.isLoggedIn ? - PlaySRGAccessibilityLocalizedString("Manages account information", comment: "Accessibility hint for the profile header when user is logged in") : - PlaySRGAccessibilityLocalizedString("allows to log in or create an account in order to synchronize data.", comment: "Accessibility hint for the profile header when user is not logged in") + data.isLoggedIn ? + PlaySRGAccessibilityLocalizedString("Manages account information", comment: "Accessibility hint for the profile header when user is logged in") : + PlaySRGAccessibilityLocalizedString("allows to log in or create an account in order to synchronize data.", comment: "Accessibility hint for the profile header when user is not logged in") } } @@ -70,38 +69,34 @@ extension ProfileAccountHeaderViewModel { struct Data: Hashable { let isLoggedIn: Bool let account: SRGAccount? - + var icon: ImageResource { - return isLoggedIn ? .accountLoggedInIcon : .accountLoggedOutIcon + isLoggedIn ? .accountLoggedInIcon : .accountLoggedOutIcon } - + var accountDescription: String? { guard isLoggedIn else { return nil } if let displayName = account?.displayName { return displayName - } - else if let emmailAddress = account?.emailAddress { + } else if let emmailAddress = account?.emailAddress { return emmailAddress - } - else { + } else { return nil } } - + var text: String { if isLoggedIn { if let accountDescription { - return accountDescription - } - else { - return NSLocalizedString("My account", comment: "Text displayed when a user is logged in but no information has been retrieved yet") + accountDescription + } else { + NSLocalizedString("My account", comment: "Text displayed when a user is logged in but no information has been retrieved yet") } - } - else { - return NSLocalizedString("Sign in", comment: "Text displayed within the sign in profile header when no user is logged in") + } else { + NSLocalizedString("Sign in", comment: "Text displayed within the sign in profile header when no user is logged in") } } - + static var notLogged = Self(isLoggedIn: false, account: nil) } } diff --git a/Application/Sources/Profile/ProfileCell+UIKit.swift b/Application/Sources/Profile/ProfileCell+UIKit.swift index ac07a73f9..7755963e0 100644 --- a/Application/Sources/Profile/ProfileCell+UIKit.swift +++ b/Application/Sources/Profile/ProfileCell+UIKit.swift @@ -15,21 +15,20 @@ extension UITableView { didSet { if let applicationSectionInfo { content = ProfileCell(applicationSectioninfo: applicationSectionInfo) - } - else { + } else { content = nil } } } } - + private static let reuseIdentifier = "ProfileCell" - + @objc func registerReusableProfileCell() { register(ProfileTableViewCell.self, forCellReuseIdentifier: Self.reuseIdentifier) } - + @objc func dequeueReusableProfileCell(for indexPath: IndexPath) -> UITableViewCell & ApplicationSectionInfoSettable { - return dequeueReusableCell(withIdentifier: Self.reuseIdentifier, for: indexPath) as! ProfileTableViewCell + dequeueReusableCell(withIdentifier: Self.reuseIdentifier, for: indexPath) as! ProfileTableViewCell } } diff --git a/Application/Sources/Profile/ProfileCell.swift b/Application/Sources/Profile/ProfileCell.swift index 7ba3fd4c6..a82b53a78 100644 --- a/Application/Sources/Profile/ProfileCell.swift +++ b/Application/Sources/Profile/ProfileCell.swift @@ -11,13 +11,13 @@ import SwiftUI struct ProfileCell: View { @Binding private(set) var applicationSectioninfo: ApplicationSectionInfo? - + @StateObject private var model = ProfileCellModel() - + init(applicationSectioninfo: ApplicationSectionInfo?) { _applicationSectioninfo = .constant(applicationSectioninfo) } - + var body: some View { MainView(model: model) .onAppear { @@ -27,29 +27,28 @@ struct ProfileCell: View { model.applicationSectioninfo = newValue } } - + /// Behavior: h-exp, v-exp private struct MainView: View { @ObservedObject var model: ProfileCellModel - + @Environment(\.isSelected) private var isSelected @Environment(\.isUIKitFocused) private var isFocused - + private let iconHeight: CGFloat = 24 - + private var accessibilityLabel: String? { if model.unreads { - return "\(model.title ?? ""), \(PlaySRGAccessibilityLocalizedString("Unreads", comment: "Unreads state button"))" - } - else { - return model.title + "\(model.title ?? ""), \(PlaySRGAccessibilityLocalizedString("Unreads", comment: "Unreads state button"))" + } else { + model.title } } - + private var accessibilityTraits: AccessibilityTraits { - return isSelected ? [.isButton, .isSelected] : .isButton + isSelected ? [.isButton, .isSelected] : .isButton } - + var body: some View { HStack(spacing: LayoutMargin) { if let image = model.image { @@ -91,7 +90,7 @@ struct ProfileCell: View { class ProfileCellSize: NSObject { @objc static func height() -> CGFloat { - return 50 + 50 } } diff --git a/Application/Sources/Profile/ProfileCellModel.swift b/Application/Sources/Profile/ProfileCellModel.swift index 51a060f96..5ccee7a57 100644 --- a/Application/Sources/Profile/ProfileCellModel.swift +++ b/Application/Sources/Profile/ProfileCellModel.swift @@ -10,9 +10,9 @@ import Combine final class ProfileCellModel: ObservableObject { @Published var applicationSectioninfo: ApplicationSectionInfo? - + @Published private(set) var unreads = false - + init() { $applicationSectioninfo .dropFirst() @@ -21,7 +21,7 @@ final class ProfileCellModel: ObservableObject { return Just(false).eraseToAnyPublisher() } return Publishers.PublishAndRepeat(onOutputFrom: ApplicationSignal.wokenUp()) { - return ApplicationSignal.hasUserUnreadNotifications() + ApplicationSignal.hasUserUnreadNotifications() } } .switchToLatest() @@ -34,14 +34,14 @@ final class ProfileCellModel: ObservableObject { extension ProfileCellModel { var image: UIImage? { - return applicationSectioninfo?.image + applicationSectioninfo?.image } - + var title: String? { - return applicationSectioninfo?.title + applicationSectioninfo?.title } - + var isModalPresentation: Bool { - return applicationSectioninfo?.isModalPresentation ?? false + applicationSectioninfo?.isModalPresentation ?? false } } diff --git a/Application/Sources/Profile/ProfileHelp.swift b/Application/Sources/Profile/ProfileHelp.swift index 0fddb5a3c..7686688e5 100644 --- a/Application/Sources/Profile/ProfileHelp.swift +++ b/Application/Sources/Profile/ProfileHelp.swift @@ -11,88 +11,88 @@ import SwiftUI @objc class ProfileHelp: NSObject { @objc static func showFeedbackForm() -> Bool { guard let url = ApplicationConfiguration.shared.userSuggestionUrlWithParameters else { return false } - + return showSafariViewController(url: url) { AnalyticsEvent.openHelp(action: .feedbackApp).send() } } - + @objc static func showFaqs() -> Bool { guard let url = ApplicationConfiguration.shared.faqURL else { return false } - + return showSafariViewController(url: url) { AnalyticsEvent.openHelp(action: .faq).send() } } - + @objc static func showStorePage() -> Bool { guard let tabBarController = UIApplication.shared.mainTabBarController else { return false } - + let productViewController = SKStoreProductViewController() productViewController.loadProduct(withParameters: [SKStoreProductParameterITunesItemIdentifier: ApplicationConfiguration.shared.appStoreProductIdentifier]) - + tabBarController.play_top.present(productViewController, animated: true) { AnalyticsEvent.openHelp(action: .evaluateApp).send() } return true } - + @objc static func showSupporByEmail() -> Bool { guard let supportEmailAddress = ApplicationConfiguration.shared.supportEmailAddress, let tabBarController = UIApplication.shared.mainTabBarController else { return false } - + if MailComposeView.canSendMail() { tabBarController.play_top.present(supportEmailMailComposeViewController(supportEmailAddress), animated: true) { AnalyticsEvent.openHelp(action: .technicalIssue).send() } - } - else { + } else { tabBarController.play_top.present(supporEmailAlertController(supportEmailAddress), animated: true) { AnalyticsEvent.openHelp(action: .technicalIssue).send() } } return true } - + private static func showSafariViewController(url: URL, completion: @escaping () -> Void) -> Bool { guard let tabBarController = UIApplication.shared.mainTabBarController else { return false } - + let safariViewController = SFSafariViewController(url: url) safariViewController.preferredBarTintColor = UIColor.srgGray16 safariViewController.preferredControlTintColor = UIColor.srgGrayD2 safariViewController.modalPresentationStyle = .pageSheet - + tabBarController.play_top.present(safariViewController, animated: true, completion: completion) return true } - + private static var supportEmailAdress: String? { - return ApplicationConfiguration.shared.supportEmailAddress + ApplicationConfiguration.shared.supportEmailAddress } - + private static func copySupportMailAdress() { UIPasteboard.general.string = supportEmailAdress } - + private static func copySupportInformation() { UIPasteboard.general.string = SupportInformation.generate() } - + private static func supportEmailMailComposeViewController(_ supportEmailAdress: String) -> UIViewController { let mailComposeView = MailComposeView() .toRecipients([supportEmailAdress]) .subject(NSLocalizedString("Report a technical issue", comment: "Subject of the technical issue mail")) .messageBody(SupportInformation.generate(toMailBody: true)) - + return UIHostingController(rootView: mailComposeView) } - + private static func supporEmailAlertController(_ supportEmailAdress: String) -> UIAlertController { let alertViewController = UIAlertController( title: NSLocalizedString("No mail application found", comment: "Missing mail application alert title"), message: String(format: NSLocalizedString("Please contact us at %@", comment: "Missing mail application description"), supportEmailAdress), - preferredStyle: .alert) - + preferredStyle: .alert + ) + alertViewController.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "Title of a cancel button"), style: .cancel)) alertViewController.addAction(UIAlertAction(title: String(format: NSLocalizedString("Copy %@", comment: "Label of the button to copy support email to the pasteboard"), supportEmailAdress), style: .default, handler: { _ in copySupportMailAdress() @@ -112,7 +112,7 @@ import SwiftUI sticky: false ) })) - + return alertViewController } } diff --git a/Application/Sources/Profile/ProfileSectionHeaderView+UIKit.swift b/Application/Sources/Profile/ProfileSectionHeaderView+UIKit.swift index cf3d6d6ec..306b76f5b 100644 --- a/Application/Sources/Profile/ProfileSectionHeaderView+UIKit.swift +++ b/Application/Sources/Profile/ProfileSectionHeaderView+UIKit.swift @@ -16,21 +16,20 @@ extension UITableView { didSet { if let title { content = ProfileSectionHeaderView(title: title) - } - else { + } else { content = nil } } } } - + private static let reuseIdentifier = "ProfileSectionHeaderView" - + @objc func registerReusableProfileSectionHeader() { register(ProfileSectionTableViewHeaderView.self, forHeaderFooterViewReuseIdentifier: Self.reuseIdentifier) } - + @objc func dequeueReusableProfileSectionHeader() -> UITableViewHeaderFooterView & ProfileSectionSettable { - return dequeueReusableHeaderFooterView(withIdentifier: UITableView.reuseIdentifier) as! ProfileSectionTableViewHeaderView + dequeueReusableHeaderFooterView(withIdentifier: UITableView.reuseIdentifier) as! ProfileSectionTableViewHeaderView } } diff --git a/Application/Sources/Profile/ProfileSectionHeaderView.swift b/Application/Sources/Profile/ProfileSectionHeaderView.swift index 8e46b3807..07526bf19 100644 --- a/Application/Sources/Profile/ProfileSectionHeaderView.swift +++ b/Application/Sources/Profile/ProfileSectionHeaderView.swift @@ -8,7 +8,7 @@ import SwiftUI struct ProfileSectionHeaderView: View { let title: String - + var body: some View { VStack(spacing: 0) { Spacer(minLength: 0) diff --git a/Application/Sources/Profile/ProfileViewController.m b/Application/Sources/Profile/ProfileViewController.m index e13572cc5..c523d9c9b 100755 --- a/Application/Sources/Profile/ProfileViewController.m +++ b/Application/Sources/Profile/ProfileViewController.m @@ -155,7 +155,7 @@ - (BOOL)openHelpSectionInfo:(ApplicationSectionInfo *)applicationSectionInfo opened = [ProfileHelp showFaqs]; break; } - + case ApplicationSectionTechnicaIssue: { opened = [ProfileHelp showSupporByEmail]; break; diff --git a/Application/Sources/ProgramGuide/ChannelHeaderView.swift b/Application/Sources/ProgramGuide/ChannelHeaderView.swift index 587b27eb5..96fc1a642 100644 --- a/Application/Sources/ProgramGuide/ChannelHeaderView.swift +++ b/Application/Sources/ProgramGuide/ChannelHeaderView.swift @@ -12,11 +12,11 @@ import SwiftUI /// Behavior: h-exp, v-exp struct ChannelHeaderView: View { let channel: SRGChannel - + private var imageUrl: URL? { - return url(for: channel.rawImage, size: .small) + url(for: channel.rawImage, size: .small) } - + var body: some View { Group { if let imageUrl { @@ -25,13 +25,11 @@ struct ChannelHeaderView: View { image .resizingMode(.aspectFit) .frame(maxWidth: 50, maxHeight: 50) - } - else { + } else { TitleView(channel: channel) } } - } - else { + } else { TitleView(channel: channel) } } @@ -45,10 +43,10 @@ struct ChannelHeaderView: View { ) .accessibilityHidden(true) } - + private struct TitleView: View { let channel: SRGChannel - + var body: some View { Text(channel.title) .srgFont(.body) diff --git a/Application/Sources/ProgramGuide/LoadingCell.swift b/Application/Sources/ProgramGuide/LoadingCell.swift index 2e9855f20..46227d537 100644 --- a/Application/Sources/ProgramGuide/LoadingCell.swift +++ b/Application/Sources/ProgramGuide/LoadingCell.swift @@ -11,7 +11,7 @@ import SwiftUI struct LoadingCell: View { @State private var appeared = false - + var body: some View { Color(appeared ? .srgGray33 : .srgGray23) .animation(Animation.linear(duration: 1).repeatForever(autoreverses: true), value: appeared) @@ -25,7 +25,7 @@ struct LoadingCell: View { struct LoadingCell_Previews: PreviewProvider { private static let height: CGFloat = constant(iOS: 80, tvOS: 120) - + static var previews: some View { LoadingCell() .previewLayout(.fixed(width: 500, height: height)) diff --git a/Application/Sources/ProgramGuide/NowArrowView.swift b/Application/Sources/ProgramGuide/NowArrowView.swift index b11340cce..f5da0bafd 100644 --- a/Application/Sources/ProgramGuide/NowArrowView.swift +++ b/Application/Sources/ProgramGuide/NowArrowView.swift @@ -11,13 +11,13 @@ import SwiftUI /// Behavior: h-hug, v-hug struct NowArrowView: View { static let size = CGSize(width: 13, height: 8) - + var body: some View { Triangle() .fill(.white) .frame(width: Self.size.width, height: Self.size.height) } - + private struct Triangle: Shape { func path(in rect: CGRect) -> Path { var path = Path() diff --git a/Application/Sources/ProgramGuide/ProgramCell.swift b/Application/Sources/ProgramGuide/ProgramCell.swift index 57a358c15..596a521c2 100644 --- a/Application/Sources/ProgramGuide/ProgramCell.swift +++ b/Application/Sources/ProgramGuide/ProgramCell.swift @@ -12,25 +12,25 @@ import SwiftUI struct ProgramCell: View { @Binding var data: ProgramAndChannel let direction: StackDirection - + @StateObject private var model = ProgramCellViewModel() - + @Environment(\.isSelected) private var isSelected - + init(program: SRGProgram, channel: PlayChannel, direction: StackDirection) { _data = .constant(.init(program: program, channel: channel)) self.direction = direction } - + var body: some View { Group { -#if os(tvOS) - MainView(model: model, direction: direction) -#else - MainView(model: model, direction: direction) - .selectionAppearance(.dimmed, when: isSelected) - .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint) -#endif + #if os(tvOS) + MainView(model: model, direction: direction) + #else + MainView(model: model, direction: direction) + .selectionAppearance(.dimmed, when: isSelected) + .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint) + #endif } .onAppear { model.data = data @@ -39,48 +39,48 @@ struct ProgramCell: View { model.data = newValue } } - + /// Behavior: h-exp, v-exp private struct MainView: View { @ObservedObject var model: ProgramCellViewModel let direction: StackDirection - + @SRGScaledMetric var timeRangeFixedWidth: CGFloat = 90 @State private var availableSize: CGSize = .zero @Environment(\.isUIKitFocused) private var isFocused - + private var timeRangeWidth: CGFloat { - return direction == .horizontal ? timeRangeFixedWidth : .infinity + direction == .horizontal ? timeRangeFixedWidth : .infinity } - + private var timeRangeLineLimit: Int { - return direction == .horizontal ? 2 : 1 + direction == .horizontal ? 2 : 1 } - + private var alignment: StackAlignment { - return direction == .horizontal ? .center : .leading + direction == .horizontal ? .center : .leading } - + private var horizontalPadding: CGFloat { - return direction == .horizontal ? 16 : 12 + direction == .horizontal ? 16 : 12 } - + private var verticalPadding: CGFloat { - return direction == .horizontal ? 0 : constant(iOS: 4, tvOS: 8) + direction == .horizontal ? 0 : constant(iOS: 4, tvOS: 8) } - + private var spacing: CGFloat { - return direction == .horizontal ? 10 : constant(iOS: 4, tvOS: 0) + direction == .horizontal ? 10 : constant(iOS: 4, tvOS: 0) } - + private var isCompact: Bool { - return availableSize.width < 100 + availableSize.width < 100 } - + private var isDisplayable: Bool { - return availableSize.width > 2 * horizontalPadding + 5 + availableSize.width > 2 * horizontalPadding + 5 } - + var body: some View { ZStack { Stack(direction: direction, alignment: alignment, spacing: spacing) { @@ -94,8 +94,7 @@ struct ProgramCell: View { } TitleView(model: model, compact: isCompact) .frame(maxWidth: .infinity, alignment: .leading) - } - else { + } else { Color.clear } } @@ -103,7 +102,7 @@ struct ProgramCell: View { .padding(.vertical, verticalPadding) .frame(maxHeight: .infinity) .background(!isFocused ? Color.srgGray23 : Color.srgGray33) - + if direction == .horizontal, let progress = model.progress { ProgressBar(value: progress) .frame(height: LayoutProgressBarHeight) @@ -116,17 +115,17 @@ struct ProgramCell: View { } } } - + /// Behavior: h-hug, v-hug private struct TitleView: View { @ObservedObject var model: ProgramCellViewModel let compact: Bool - + private let canPlayHeight: CGFloat = 24 - + var body: some View { HStack(spacing: 10) { - if !compact && model.canPlay { + if !compact, model.canPlay { Image(.playCircle) .foregroundColor(.srgGrayD2) .frame(height: canPlayHeight) @@ -147,11 +146,11 @@ struct ProgramCell: View { private extension ProgramCell { var accessibilityLabel: String? { - return model.accessibilityLabel + model.accessibilityLabel } - + var accessibilityHint: String? { - return PlaySRGAccessibilityLocalizedString("Opens details.", comment: "Program cell hint") + PlaySRGAccessibilityLocalizedString("Opens details.", comment: "Program cell hint") } } @@ -159,7 +158,7 @@ private extension ProgramCell { enum ProgramCellSize { static func fullWidth() -> NSCollectionLayoutSize { - return NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(50)) + NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(50)) } } @@ -168,7 +167,7 @@ enum ProgramCellSize { struct ProgramCell_Previews: PreviewProvider { private static let size = ProgramCellSize.fullWidth().previewSize private static let height: CGFloat = constant(iOS: 80, tvOS: 120) - + static var previews: some View { ProgramCell(program: Mock.program(), channel: Mock.playChannel(), direction: .horizontal) .previewLayout(.fixed(width: size.width, height: size.height)) diff --git a/Application/Sources/ProgramGuide/ProgramCellViewModel.swift b/Application/Sources/ProgramGuide/ProgramCellViewModel.swift index 03c0cc55b..40c77c5ac 100644 --- a/Application/Sources/ProgramGuide/ProgramCellViewModel.swift +++ b/Application/Sources/ProgramGuide/ProgramCellViewModel.swift @@ -12,21 +12,21 @@ import SRGDataProviderModel final class ProgramCellViewModel: ObservableObject { @Published var data: ProgramAndChannel? @Published private(set) var date = Date() - + init() { Timer.publish(every: 10, on: .main, in: .common) .autoconnect() .assign(to: &$date) } - + var title: String? { - return data?.program.title + data?.program.title } - + var accessibilityLabel: String? { - return data?.program.play_accessibilityLabel(with: data?.channel.wrappedValue) + data?.program.play_accessibilityLabel(with: data?.channel.wrappedValue) } - + var timeRange: String? { guard let program = data?.program else { return nil } let startTime = DateFormatter.play_time.string(from: program.startDate) @@ -34,17 +34,17 @@ final class ProgramCellViewModel: ObservableObject { // Unbreakable spaces before / after the separator return "\(startTime) - \(endTime)" } - + var canPlay: Bool { guard let channel = data?.channel, !channel.external else { return false } return progress != nil || data?.program.mediaURN != nil } - + var progress: Double? { guard let program = data?.program else { return nil } let progress = date.timeIntervalSince(program.startDate) / program.endDate.timeIntervalSince(program.startDate) - return (0...1).contains(progress) ? progress : nil + return (0 ... 1).contains(progress) ? progress : nil } } diff --git a/Application/Sources/ProgramGuide/ProgramGuideCalendarViewController.swift b/Application/Sources/ProgramGuide/ProgramGuideCalendarViewController.swift index 0fdfe9ecc..63689d8b0 100644 --- a/Application/Sources/ProgramGuide/ProgramGuideCalendarViewController.swift +++ b/Application/Sources/ProgramGuide/ProgramGuideCalendarViewController.swift @@ -10,25 +10,26 @@ import Foundation final class ProgramGuideCalendarViewController: UIViewController { private let model: ProgramGuideViewModel - + private weak var calendarView: HostView! - + init(model: ProgramGuideViewModel) { self.model = model super.init(nibName: nil, bundle: nil) - + modalPresentationStyle = .overFullScreen modalTransitionStyle = .crossDissolve } - - required init?(coder: NSCoder) { + + @available(*, unavailable) + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func loadView() { let view = UIView(frame: UIScreen.main.bounds) view.backgroundColor = .clear - + let blurEffect = UIBlurEffect(style: .systemUltraThinMaterial) let blurEffectView = UIVisualEffectView(effect: blurEffect) view.addSubview(blurEffectView) @@ -40,11 +41,11 @@ final class ProgramGuideCalendarViewController: UIViewController { blurEffectView.leadingAnchor.constraint(equalTo: view.leadingAnchor), blurEffectView.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) - + let calendarView = HostView() view.addSubview(calendarView) self.calendarView = calendarView - + calendarView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ calendarView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), @@ -52,10 +53,10 @@ final class ProgramGuideCalendarViewController: UIViewController { calendarView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), calendarView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor) ]) - + self.view = view } - + override func viewDidLoad() { super.viewDidLoad() calendarView.content = CalendarView(model: model) diff --git a/Application/Sources/ProgramGuide/ProgramGuideDailyViewController.swift b/Application/Sources/ProgramGuide/ProgramGuideDailyViewController.swift index 533fecb8b..f054ffc17 100644 --- a/Application/Sources/ProgramGuide/ProgramGuideDailyViewController.swift +++ b/Application/Sources/ProgramGuide/ProgramGuideDailyViewController.swift @@ -13,21 +13,21 @@ import UIKit final class ProgramGuideDailyViewController: UIViewController { private let model: ProgramGuideDailyViewModel private let programGuideModel: ProgramGuideViewModel - + private var scrollTargetTime: TimeInterval? private var cancellables = Set() private var dataSource: UICollectionViewDiffableDataSource! - + private weak var collectionView: UICollectionView! private weak var emptyContentView: HostView! - + private static let layoutHorizontalMargin: CGFloat = 10 private static let verticalSpacing: CGFloat = 3 - + var day: SRGDay { - return model.day + model.day } - + private static func snapshot(from state: ProgramGuideDailyViewModel.State, for channel: PlayChannel?) -> NSDiffableDataSourceSnapshot { var snapshot = NSDiffableDataSourceSnapshot() if let channel { @@ -36,33 +36,33 @@ final class ProgramGuideDailyViewController: UIViewController { } return snapshot } - + init(day: SRGDay, programGuideModel: ProgramGuideViewModel, programGuideDailyModel: ProgramGuideDailyViewModel? = nil) { if let programGuideDailyModel, programGuideDailyModel.day == programGuideModel.day { model = programGuideDailyModel - } - else { + } else { model = ProgramGuideDailyViewModel(day: day, mainPartyChannels: programGuideModel.mainPartyChannels, otherPartyChannels: programGuideModel.otherPartyChannels) } self.programGuideModel = programGuideModel scrollTargetTime = programGuideModel.time super.init(nibName: nil, bundle: nil) } - - required init?(coder: NSCoder) { + + @available(*, unavailable) + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func loadView() { let view = UIView(frame: UIScreen.main.bounds) - + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout()) collectionView.delegate = self collectionView.backgroundColor = .clear - + view.addSubview(collectionView) self.collectionView = collectionView - + collectionView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ collectionView.topAnchor.constraint(equalTo: view.topAnchor), @@ -70,37 +70,37 @@ final class ProgramGuideDailyViewController: UIViewController { collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: Self.layoutHorizontalMargin), collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -Self.layoutHorizontalMargin) ]) - + let emptyContentView = HostView(frame: .zero) collectionView.backgroundView = emptyContentView self.emptyContentView = emptyContentView - + self.view = view } - + override func viewDidLoad() { super.viewDidLoad() - + let cellRegistration = UICollectionView.CellRegistration, ProgramGuideDailyViewModel.Item> { cell, _, item in cell.content = ItemCell(item: item) } - + dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item in - return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item) + collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item) } - + model.$state .sink { [weak self] state in self?.reloadData(for: state) } .store(in: &cancellables) - + programGuideModel.$data .sink { [weak self] data in self?.reloadData(for: data.selectedChannel) } .store(in: &cancellables) - + programGuideModel.$change .sink { [weak self] change in guard let self else { return } @@ -115,35 +115,33 @@ final class ProgramGuideDailyViewController: UIViewController { } .store(in: &cancellables) } - + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - + scrollToTime(programGuideModel.time, animated: false) } - + private func reloadData(for channel: PlayChannel? = nil) { reloadData(for: model.state, channel: channel) } - + private func reloadData(for state: ProgramGuideDailyViewModel.State, channel: PlayChannel? = nil) { - let currentChannel = channel ?? self.programGuideModel.selectedChannel - + let currentChannel = channel ?? programGuideModel.selectedChannel + switch state { case let .failed(error: error): emptyContentView.content = EmptyContentView(state: .failed(error: error)) case .content: if state.isLoading(in: currentChannel) { emptyContentView.content = EmptyContentView(state: .loading) - } - else if state.isEmpty(in: currentChannel) { + } else if state.isEmpty(in: currentChannel) { emptyContentView.content = EmptyContentView(state: .empty(type: .generic)) - } - else { + } else { emptyContentView.content = nil } } - + DispatchQueue.global(qos: .userInteractive).async { self.dataSource.apply(Self.snapshot(from: state, for: currentChannel), animatingDifferences: false) { if let channel = currentChannel, !state.isEmpty(in: channel) { @@ -155,13 +153,12 @@ final class ProgramGuideDailyViewController: UIViewController { } } } - + private func scrollToTime(_ time: TimeInterval?, animated: Bool) { if let time, let yOffset = yOffset(for: day.date.addingTimeInterval(time)) { collectionView.setContentOffset(CGPoint(x: collectionView.contentOffset.x, y: yOffset), animated: animated) scrollTargetTime = nil - } - else { + } else { scrollTargetTime = time } } @@ -173,16 +170,16 @@ extension ProgramGuideDailyViewController { private static func safeYOffset(_ yOffset: CGFloat, in collectionView: UICollectionView) -> CGFloat { let maxYOffset = max(collectionView.contentSize.height - collectionView.frame.height + collectionView.adjustedContentInset.top + collectionView.adjustedContentInset.bottom, 0) - return yOffset.clamped(to: 0...maxYOffset) + return yOffset.clamped(to: 0 ... maxYOffset) } - + func date(atYOffset yOffset: CGFloat) -> Date? { guard let selectedChannel = programGuideModel.selectedChannel, let index = collectionView.indexPathForItem(at: CGPoint(x: collectionView.contentOffset.x, y: yOffset))?.row, let program = model.state.items(for: selectedChannel)[safeIndex: index]?.program else { return nil } return program.startDate } - + func yOffset(for date: Date) -> CGFloat? { guard collectionView.contentSize != .zero, let selectedChannel = programGuideModel.selectedChannel, @@ -196,27 +193,27 @@ extension ProgramGuideDailyViewController { extension ProgramGuideDailyViewController: ProgramGuideChildViewController { var programGuideLayout: ProgramGuideLayout { - return .list + .list } - + var programGuideDailyViewModel: ProgramGuideDailyViewModel? { - return model + model } } extension ProgramGuideDailyViewController: ContentInsets { var play_contentScrollViews: [UIScrollView]? { - return collectionView != nil ? [collectionView] : nil + collectionView != nil ? [collectionView] : nil } - + var play_paddingContentInsets: UIEdgeInsets { - return UIEdgeInsets(top: 0, left: 0, bottom: Self.verticalSpacing, right: 0) + UIEdgeInsets(top: 0, left: 0, bottom: Self.verticalSpacing, right: 0) } } extension ProgramGuideDailyViewController: ScrollableContent { var play_scrollableView: UIScrollView? { - return collectionView + collectionView } } @@ -225,11 +222,12 @@ extension ProgramGuideDailyViewController: UICollectionViewDelegate { // Deselection is managed here rather than in view appearance methods, as those are not called with the // modal presentation we use. guard let channel = programGuideModel.selectedChannel, - let program = dataSource.snapshot().itemIdentifiers(inSection: channel)[indexPath.row].program else { + let program = dataSource.snapshot().itemIdentifiers(inSection: channel)[indexPath.row].program + else { deselectItems(in: collectionView, animated: true) return } - + AnalyticsClickEvent.tvGuideOpenInfoBox(program: program, programGuideLayout: .list).send() let programViewController = ProgramView.viewController(for: program, channel: channel) present(programViewController, animated: true) { @@ -239,11 +237,11 @@ extension ProgramGuideDailyViewController: UICollectionViewDelegate { } extension ProgramGuideDailyViewController: UIScrollViewDelegate { - func scrollViewDidScroll(_ scrollView: UIScrollView) { + func scrollViewDidScroll(_: UIScrollView) { guard let date = date(atYOffset: collectionView.contentOffset.y) else { return } programGuideModel.didScrollToTime(date.timeIntervalSince(day.date)) } - + // The system default behavior does not lead to correct results when large titles are displayed. Override. func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { scrollView.play_scrollToTop(animated: true) @@ -256,12 +254,11 @@ extension ProgramGuideDailyViewController: UIScrollViewDelegate { private extension ProgramGuideDailyViewController { struct ItemCell: View { let item: ProgramGuideDailyViewModel.Item - + var body: some View { if let program = item.program { ProgramCell(program: program, channel: item.section, direction: .horizontal) - } - else { + } else { Color.clear } } @@ -272,10 +269,10 @@ private extension ProgramGuideDailyViewController { private extension ProgramGuideDailyViewController { private func layout() -> UICollectionViewLayout { - return UICollectionViewCompositionalLayout { _, layoutEnvironment in + UICollectionViewCompositionalLayout { _, layoutEnvironment in let layoutWidth = layoutEnvironment.container.effectiveContentSize.width let section = NSCollectionLayoutSection.horizontal(layoutWidth: layoutWidth) { _, _ in - return ProgramCellSize.fullWidth() + ProgramCellSize.fullWidth() } section.interGroupSpacing = Self.verticalSpacing return section diff --git a/Application/Sources/ProgramGuide/ProgramGuideDailyViewModel.swift b/Application/Sources/ProgramGuide/ProgramGuideDailyViewModel.swift index 3a773e4c8..7370c4101 100644 --- a/Application/Sources/ProgramGuide/ProgramGuideDailyViewModel.swift +++ b/Application/Sources/ProgramGuide/ProgramGuideDailyViewModel.swift @@ -12,18 +12,18 @@ import SRGDataProviderCombine final class ProgramGuideDailyViewModel: ObservableObject { @Published var day: SRGDay @Published private(set) var state: State - + /// Channels can be provided if available for more efficient content loading init(day: SRGDay, mainPartyChannels: [PlayChannel], otherPartyChannels: [PlayChannel]) { self.day = day - self.state = .loading(mainPartyChannels: mainPartyChannels, otherPartyChannels: otherPartyChannels, day: day) - + state = .loading(mainPartyChannels: mainPartyChannels, otherPartyChannels: otherPartyChannels, day: day) + Publishers.PublishAndRepeat(onOutputFrom: ApplicationSignal.wokenUp()) { [weak self, $day] in $day .map { day in - return Self.state(from: self?.state, for: day) + Self.state(from: self?.state, for: day) .catch { error in - return Just(.failed(error: error)) + Just(.failed(error: error)) } } .switchToLatest() @@ -37,109 +37,109 @@ final class ProgramGuideDailyViewModel: ObservableObject { extension ProgramGuideDailyViewModel { typealias Section = PlayChannel - + struct Item: Hashable { enum WrappedValue: Hashable { case program(_ program: SRGProgram) case empty case loading } - + let wrappedValue: WrappedValue let section: Section - + // Only attached to items so that `ProgramGuideGridLayout` can retrieve the current day from a snapshot let day: SRGDay - + fileprivate init(wrappedValue: WrappedValue, section: Section, day: SRGDay) { self.wrappedValue = wrappedValue self.section = section self.day = day } - + var program: SRGProgram? { switch wrappedValue { case let .program(program): - return program + program default: - return nil + nil } } - + func endsAfter(_ date: Date) -> Bool { switch wrappedValue { case let .program(program): - return program.endDate > date + program.endDate > date default: - return false + false } } } - + enum Bouquet { case loading(channels: [PlayChannel]) case content(programCompositions: [PlayProgramComposition]) - + fileprivate static var empty: Self { - return .content(programCompositions: []) + .content(programCompositions: []) } - + fileprivate var isLoading: Bool { switch self { case .loading: - return true + true case .content: - return false + false } } - + fileprivate var isEmpty: Bool { switch self { case .loading: - return false + false case let .content(programCompositions: programCompositions): - return programCompositions.allSatisfy { $0.programs?.isEmpty ?? true } + programCompositions.allSatisfy { $0.programs?.isEmpty ?? true } } } - + fileprivate var hasPrograms: Bool { switch self { case .loading: - return false + false case let .content(programCompositions: programCompositions): - return programCompositions.contains { programComposition in + programCompositions.contains { programComposition in guard let programs = programComposition.programs else { return false } return !programs.isEmpty } } } - + fileprivate var channels: [PlayChannel] { switch self { case let .loading(channels: channels): - return channels + channels case let .content(programCompositions: programCompositions): - return programCompositions.map(\.channel) + programCompositions.map(\.channel) } } - + fileprivate func contains(channel: PlayChannel) -> Bool { - return channels.contains(channel) + channels.contains(channel) } - + private static func programs(for channel: PlayChannel, in programCompositions: [PlayProgramComposition]) -> [SRGProgram] { - return programCompositions.first(where: { $0.channel == channel })?.programs ?? [] + programCompositions.first(where: { $0.channel == channel })?.programs ?? [] } - + fileprivate func isEmpty(for channel: PlayChannel) -> Bool { switch self { case .loading: - return false + false case let .content(programCompositions: programCompositions): - return Self.programs(for: channel, in: programCompositions).isEmpty + Self.programs(for: channel, in: programCompositions).isEmpty } } - + fileprivate func items(for channel: PlayChannel, day: SRGDay) -> [Item] { switch self { case .loading: @@ -148,93 +148,88 @@ extension ProgramGuideDailyViewModel { let programs = Self.programs(for: channel, in: programCompositions) if !programs.isEmpty { return programs.map { Item(wrappedValue: .program($0), section: channel, day: day) } - } - else { + } else { return [Item(wrappedValue: .empty, section: channel, day: day)] } } } } - + enum State { case content(mainPartyBouquet: Bouquet, otherPartyBouquet: Bouquet, day: SRGDay) case failed(error: Error) - + fileprivate static func loading(mainPartyChannels: [PlayChannel], otherPartyChannels: [PlayChannel], day: SRGDay) -> Self { - return .content(mainPartyBouquet: .loading(channels: mainPartyChannels), otherPartyBouquet: .loading(channels: otherPartyChannels), day: day) + .content(mainPartyBouquet: .loading(channels: mainPartyChannels), otherPartyBouquet: .loading(channels: otherPartyChannels), day: day) } - + private var day: SRGDay? { switch self { case let .content(mainPartyBouquet: _, otherPartyBouquet: _, day: day): - return day + day case .failed: - return nil + nil } } - + private var bouquets: [Bouquet] { switch self { case let .content(mainPartyBouquet: mainPartyBouquet, otherPartyBouquet: otherPartyBouquet, day: _): - return [mainPartyBouquet, otherPartyBouquet] + [mainPartyBouquet, otherPartyBouquet] case .failed: - return [] + [] } } - + var sections: [Section] { - return bouquets.flatMap(\.channels) + bouquets.flatMap(\.channels) } - + private func bouquet(for section: Section) -> Bouquet? { switch self { case let .content(mainPartyBouquet: mainPartyBouquet, otherPartyBouquet: otherPartyBouquet, day: _): if mainPartyBouquet.contains(channel: section) { - return mainPartyBouquet - } - else if otherPartyBouquet.contains(channel: section) { - return otherPartyBouquet - } - else { - return nil + mainPartyBouquet + } else if otherPartyBouquet.contains(channel: section) { + otherPartyBouquet + } else { + nil } case .failed: - return nil + nil } } - + func items(for section: Section) -> [Item] { guard let day, let bouquet = bouquet(for: section) else { return [] } return bouquet.items(for: section, day: day) } - + func isLoading(in section: Section?) -> Bool { if let section { guard let bouquet = bouquet(for: section) else { return false } return bouquet.isLoading - } - else { + } else { // Grid layout: Do not display any loading indicator when the channel list is known return sections.isEmpty } } - + var isLoading: Bool { - return isLoading(in: nil) + isLoading(in: nil) } - + func isEmpty(in section: Section?) -> Bool { if let section { guard let bouquet = bouquet(for: section) else { return false } return bouquet.isEmpty(for: section) - } - else { - return bouquets.allSatisfy { $0.isEmpty } + } else { + return bouquets.allSatisfy(\.isEmpty) } } - + var isEmpty: Bool { - return isEmpty(in: nil) + isEmpty(in: nil) } } } @@ -252,14 +247,13 @@ private extension ProgramGuideDailyViewModel { ) .map { .content(mainPartyBouquet: $0, otherPartyBouquet: $1, day: day) } .eraseToAnyPublisher() - } - else { + } else { return Self.bouquet(for: vendor, mainProvider: true, day: day, from: state) .map { .content(mainPartyBouquet: $0, otherPartyBouquet: .empty, day: day) } .eraseToAnyPublisher() } } - + static func bouquet(from state: State?, for mainProvider: Bool, day otherDay: SRGDay) -> Bouquet { guard let state else { return .empty } switch state { @@ -272,8 +266,8 @@ private extension ProgramGuideDailyViewModel { return .empty } } - - static func bouquet(for vendor: SRGVendor, mainProvider: Bool, day: SRGDay, from state: State?) -> AnyPublisher { + + static func bouquet(for _: SRGVendor, mainProvider: Bool, day: SRGDay, from state: State?) -> AnyPublisher { let bouquet = bouquet(from: state, for: mainProvider, day: day) return SRGDataProvider.current!.tvProgramsPublisher(day: day, mainProvider: mainProvider, minimal: true) .append(SRGDataProvider.current!.tvProgramsPublisher(day: day, mainProvider: mainProvider)) diff --git a/Application/Sources/ProgramGuide/ProgramGuideGridLayout.swift b/Application/Sources/ProgramGuide/ProgramGuideGridLayout.swift index 330450256..bac411f5a 100644 --- a/Application/Sources/ProgramGuide/ProgramGuideGridLayout.swift +++ b/Application/Sources/ProgramGuide/ProgramGuideGridLayout.swift @@ -45,45 +45,45 @@ final class ProgramGuideGridLayout: UICollectionViewLayout { case nowArrow case nowLine } - + static let decorationIndexPath = IndexPath(item: 0, section: 0) static let timelineHeight: CGFloat = constant(iOS: 40, tvOS: 60) static let timelinePadding: CGFloat = 1000 static let channelHeaderWidth: CGFloat = 82 static let horizontalSpacing: CGFloat = constant(iOS: 2, tvOS: 4) static let verticalSpacing: CGFloat = constant(iOS: 3, tvOS: 6) - + private struct LayoutData { let layoutAttrs: [UICollectionViewLayoutAttributes] let supplementaryAttrs: [UICollectionViewLayoutAttributes] let decorationAttrs: [UICollectionViewLayoutAttributes] let dateInterval: DateInterval } - + private static let scale: CGFloat = constant(iOS: 430, tvOS: 900) / (60 * 60) private static let sectionHeight: CGFloat = constant(iOS: 80, tvOS: 120) private static let trailingMargin: CGFloat = 10 - + private var layoutData: LayoutData? private var cancellables = Set() - + private static func startDate(from snapshot: NSDiffableDataSourceSnapshot) -> Date? { guard let section = snapshot.sectionIdentifiers.first(where: { section in - return !snapshot.itemIdentifiers(inSection: section).isEmpty + !snapshot.itemIdentifiers(inSection: section).isEmpty }) else { return nil } return snapshot.itemIdentifiers(inSection: section).first?.day.date } - + private static func endDate(from startDate: Date) -> Date { let dateComponent = DateComponents(day: 1, hour: 3) return Calendar.srgDefault.date(byAdding: dateComponent, to: startDate)! } - + private static func dateInterval(from snapshot: NSDiffableDataSourceSnapshot) -> DateInterval? { guard let startDate = startDate(from: snapshot) else { return nil } return DateInterval(start: startDate, end: endDate(from: startDate)) } - + private static func frame(from startDate: Date, to endDate: Date, in dateInterval: DateInterval, forSection section: Int, collectionView: UICollectionView) -> CGRect { // Adjust the frame of items which would be partially visible otherwise. Two different behaviors are implemented // for iOS and tvOS: @@ -108,16 +108,15 @@ final class ProgramGuideGridLayout: UICollectionViewLayout { ) return frame.intersects(visibleFrame) ? frame.intersection(visibleFrame) : frame } - + private static func layoutData(from snapshot: NSDiffableDataSourceSnapshot, in collectionView: UICollectionView) -> LayoutData? { guard let dateInterval = dateInterval(from: snapshot) else { return nil } let layoutAttrs = snapshot.sectionIdentifiers.enumeratedFlatMap { channel, section in - return snapshot.itemIdentifiers(inSection: channel).enumeratedMap { item, index in + snapshot.itemIdentifiers(inSection: channel).enumeratedMap { item, index in let attrs = UICollectionViewLayoutAttributes(forCellWith: IndexPath(item: index, section: section)) if let program = item.program { attrs.frame = frame(from: program.startDate, to: program.endDate, in: dateInterval, forSection: section, collectionView: collectionView) - } - else { + } else { attrs.frame = frame(from: dateInterval.start, to: dateInterval.end, in: dateInterval, forSection: section, collectionView: collectionView) } return attrs @@ -134,7 +133,7 @@ final class ProgramGuideGridLayout: UICollectionViewLayout { attrs.zIndex = 2 return attrs } - + let timelineAttr = TimelineLayoutAttributes(forDecorationViewOfKind: ElementKind.timeline.rawValue, with: IndexPath(item: 0, section: 0)) timelineAttr.frame = CGRect( x: -timelinePadding, @@ -144,27 +143,27 @@ final class ProgramGuideGridLayout: UICollectionViewLayout { ) timelineAttr.dateInterval = dateInterval timelineAttr.zIndex = 3 - + let nowDate = Date() var decorationAttrs: [UICollectionViewLayoutAttributes] = [timelineAttr] if !snapshot.sectionIdentifiers.isEmpty, dateInterval.contains(nowDate) { let nowHeadAttr = nowArrowAttr(at: nowDate, in: dateInterval, collectionView: collectionView) decorationAttrs.append(nowHeadAttr) - + let nowLineAttr = nowLineAttr(at: nowDate, in: dateInterval, collectionView: collectionView) decorationAttrs.append(nowLineAttr) } return LayoutData(layoutAttrs: layoutAttrs, supplementaryAttrs: headerAttrs, decorationAttrs: decorationAttrs, dateInterval: dateInterval) } - + private static func xPosition(at date: Date, in dateInterval: DateInterval) -> CGFloat { - return channelHeaderWidth + horizontalSpacing + date.timeIntervalSince(dateInterval.start) * scale + channelHeaderWidth + horizontalSpacing + date.timeIntervalSince(dateInterval.start) * scale } - + private static func nowXPosition(at date: Date, in dateInterval: DateInterval) -> CGFloat { - return xPosition(at: date, in: dateInterval) - NowArrowView.size.width / 2 + xPosition(at: date, in: dateInterval) - NowArrowView.size.width / 2 } - + private static func nowArrowAttr(at date: Date, in dateInterval: DateInterval, collectionView: UICollectionView) -> UICollectionViewLayoutAttributes { let attr = UICollectionViewLayoutAttributes(forDecorationViewOfKind: ElementKind.nowArrow.rawValue, with: decorationIndexPath) attr.frame = CGRect( @@ -176,7 +175,7 @@ final class ProgramGuideGridLayout: UICollectionViewLayout { attr.zIndex = 4 return attr } - + private static func nowLineAttr(at date: Date, in dateInterval: DateInterval, collectionView: UICollectionView) -> UICollectionViewLayoutAttributes { let attr = UICollectionViewLayoutAttributes(forDecorationViewOfKind: ElementKind.nowLine.rawValue, with: decorationIndexPath) attr.frame = CGRect( @@ -188,18 +187,18 @@ final class ProgramGuideGridLayout: UICollectionViewLayout { attr.zIndex = 1 return attr } - + /// Content offset with contribution of vertical bouncing taken into account private static func yElasticContentOffset(for collectionView: UICollectionView) -> CGFloat { let yBouncingOffset = max(-collectionView.contentOffset.y - collectionView.adjustedContentInset.top, 0) return collectionView.contentOffset.y + yBouncingOffset } - + private var focusedIndexPath: IndexPath? { guard let focusedCell = UIScreen.main.focusedView as? UICollectionViewCell else { return nil } return collectionView?.indexPath(for: focusedCell) } - + override init() { super.init() Timer.publish(every: 10, on: .main, in: .common) @@ -209,26 +208,26 @@ final class ProgramGuideGridLayout: UICollectionViewLayout { } .store(in: &cancellables) } - - required init?(coder: NSCoder) { + + @available(*, unavailable) + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func prepare() { super.prepare() - + if let collectionView, let dataSource = collectionView.dataSource as? UICollectionViewDiffableDataSource { layoutData = Self.layoutData(from: dataSource.snapshot(), in: collectionView) - } - else { + } else { layoutData = nil } } - - override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { - return true + + override func shouldInvalidateLayout(forBoundsChange _: CGRect) -> Bool { + true } - + override var collectionViewContentSize: CGSize { guard let collectionView, let layoutData else { return .zero } return CGSize( @@ -236,7 +235,7 @@ final class ProgramGuideGridLayout: UICollectionViewLayout { height: Self.timelineHeight + CGFloat(collectionView.numberOfSections) * Self.sectionHeight + max(CGFloat(collectionView.numberOfSections - 1), 0) * Self.verticalSpacing ) } - + override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { guard let layoutData else { return nil } let layoutAttrs = layoutData.layoutAttrs.filter { $0.frame.intersects(rect) } @@ -244,17 +243,17 @@ final class ProgramGuideGridLayout: UICollectionViewLayout { let decorationAttrs = layoutData.decorationAttrs.filter { $0.frame.intersects(rect) } return layoutAttrs + supplementaryAttrs + decorationAttrs } - + override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { - return layoutData?.layoutAttrs.first { $0.indexPath == indexPath } + layoutData?.layoutAttrs.first { $0.indexPath == indexPath } } - + override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { - return layoutData?.supplementaryAttrs.first { $0.indexPath == indexPath && $0.representedElementKind == elementKind } + layoutData?.supplementaryAttrs.first { $0.indexPath == indexPath && $0.representedElementKind == elementKind } } - + override func layoutAttributesForDecorationView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { - return layoutData?.decorationAttrs.first { $0.indexPath == indexPath && $0.representedElementKind == elementKind } + layoutData?.decorationAttrs.first { $0.indexPath == indexPath && $0.representedElementKind == elementKind } } } @@ -265,30 +264,30 @@ extension ProgramGuideGridLayout { let startDate = day.date return DateInterval(start: startDate, end: endDate(from: startDate)) } - + private static func safeXOffset(_ xOffset: CGFloat, in collectionView: UICollectionView) -> CGFloat { let maxXOffset = max(collectionView.contentSize.width - collectionView.frame.width + collectionView.adjustedContentInset.left + collectionView.adjustedContentInset.right, 0) - return xOffset.clamped(to: 0...maxXOffset) + return xOffset.clamped(to: 0 ... maxXOffset) } - + private static func safeYOffset(_ yOffset: CGFloat, in collectionView: UICollectionView) -> CGFloat { let maxYOffset = max(collectionView.contentSize.height - collectionView.frame.height + collectionView.adjustedContentInset.top + collectionView.adjustedContentInset.bottom, 0) - return yOffset.clamped(to: 0...maxYOffset) + return yOffset.clamped(to: 0 ... maxYOffset) } - + static func date(centeredAtXOffset xOffset: CGFloat, in collectionView: UICollectionView, day: SRGDay) -> Date? { let dateInterval = dateInterval(for: day) let gridWidth = max(collectionView.frame.width - channelHeaderWidth, 0) let date = dateInterval.start.addingTimeInterval(safeXOffset(xOffset + gridWidth / 2.0, in: collectionView) / scale) return dateInterval.contains(date) ? date : nil } - + static func sectionIndex(atYOffset yOffset: CGFloat, in collectionView: UICollectionView) -> Int { - return Int(safeYOffset(yOffset, in: collectionView) / (sectionHeight + verticalSpacing)) + Int(safeYOffset(yOffset, in: collectionView) / (sectionHeight + verticalSpacing)) } - + static func xOffset(centeringDate date: Date, in collectionView: UICollectionView, day: SRGDay) -> CGFloat? { guard collectionView.contentSize != .zero else { return nil } let dateInterval = dateInterval(for: day) @@ -296,7 +295,7 @@ extension ProgramGuideGridLayout { let gridWidth = max(collectionView.frame.width - channelHeaderWidth, 0) return safeXOffset(date.timeIntervalSince(dateInterval.start) * scale - gridWidth / 2.0, in: collectionView) } - + static func yOffset(forSectionIndex sectionIndex: Int, in collectionView: UICollectionView) -> CGFloat? { guard collectionView.contentSize != .zero else { return nil } return safeYOffset(CGFloat(sectionIndex) * (sectionHeight + verticalSpacing), in: collectionView) diff --git a/Application/Sources/ProgramGuide/ProgramGuideGridViewController.swift b/Application/Sources/ProgramGuide/ProgramGuideGridViewController.swift index 0156f3f6c..aab387873 100644 --- a/Application/Sources/ProgramGuide/ProgramGuideGridViewController.swift +++ b/Application/Sources/ProgramGuide/ProgramGuideGridViewController.swift @@ -14,14 +14,14 @@ import UIKit final class ProgramGuideGridViewController: UIViewController { private let model: ProgramGuideViewModel private let dailyModel: ProgramGuideDailyViewModel - + private var scrollTarget: ScrollTarget? private var cancellables = Set() private var dataSource: UICollectionViewDiffableDataSource! - + private weak var collectionView: UICollectionView! private weak var emptyContentView: HostView! - + private static func snapshot(from state: ProgramGuideDailyViewModel.State) -> NSDiffableDataSourceSnapshot { var snapshot = NSDiffableDataSourceSnapshot() for section in state.sections { @@ -30,27 +30,27 @@ final class ProgramGuideGridViewController: UIViewController { } return snapshot } - + init(model: ProgramGuideViewModel, dailyModel: ProgramGuideDailyViewModel?) { self.model = model scrollTarget = ScrollTarget(channel: model.selectedChannel, time: model.time) if let dailyModel, dailyModel.day == model.day { self.dailyModel = dailyModel - } - else { + } else { self.dailyModel = ProgramGuideDailyViewModel(day: model.day, mainPartyChannels: model.mainPartyChannels, otherPartyChannels: model.otherPartyChannels) } super.init(nibName: nil, bundle: nil) } - - required init?(coder: NSCoder) { + + @available(*, unavailable) + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func loadView() { let view = UIView(frame: UIScreen.main.bounds) view.backgroundColor = .srgGray16 - + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: ProgramGuideGridLayout()) collectionView.delegate = self collectionView.backgroundColor = .clear @@ -58,10 +58,10 @@ final class ProgramGuideGridViewController: UIViewController { collectionView.isDirectionalLockEnabled = true collectionView.horizontalScrollIndicatorInsets = UIEdgeInsets(top: 0, left: ProgramGuideGridLayout.channelHeaderWidth, bottom: 0, right: 0) collectionView.verticalScrollIndicatorInsets = UIEdgeInsets(top: ProgramGuideGridLayout.timelineHeight, left: 0, bottom: 0, right: 0) - + view.addSubview(collectionView) self.collectionView = collectionView - + collectionView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ collectionView.topAnchor.constraint(equalTo: view.topAnchor), @@ -69,56 +69,55 @@ final class ProgramGuideGridViewController: UIViewController { collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: constant(iOS: 0, tvOS: 56)), collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) - + let emptyContentView = HostView(frame: .zero) collectionView.backgroundView = emptyContentView self.emptyContentView = emptyContentView - + self.view = view } - + override func viewDidLoad() { super.viewDidLoad() - + let cellRegistration = UICollectionView.CellRegistration, ProgramGuideDailyViewModel.Item> { cell, _, item in cell.content = ItemCell(item: item) -#if os(tvOS) - if let program = item.program { - cell.accessibilityLabel = program.play_accessibilityLabel(with: item.section.wrappedValue) - cell.accessibilityHint = PlaySRGAccessibilityLocalizedString("Opens details.", comment: "Program cell hint") - } - else { - cell.accessibilityLabel = nil - cell.accessibilityHint = nil - } -#endif + #if os(tvOS) + if let program = item.program { + cell.accessibilityLabel = program.play_accessibilityLabel(with: item.section.wrappedValue) + cell.accessibilityHint = PlaySRGAccessibilityLocalizedString("Opens details.", comment: "Program cell hint") + } else { + cell.accessibilityLabel = nil + cell.accessibilityHint = nil + } + #endif } - + dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item in - return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item) + collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item) } - + let headerViewRegistration = UICollectionView.SupplementaryRegistration>(elementKind: UICollectionView.elementKindSectionHeader) { [weak self] view, _, indexPath in guard let self else { return } let snapshot = dataSource.snapshot() let channel = snapshot.sectionIdentifiers[indexPath.section] view.content = ChannelHeaderView(channel: channel.wrappedValue) } - + dataSource.supplementaryViewProvider = { collectionView, _, indexPath in - return collectionView.dequeueConfiguredReusableSupplementary(using: headerViewRegistration, for: indexPath) + collectionView.dequeueConfiguredReusableSupplementary(using: headerViewRegistration, for: indexPath) } - + collectionView.collectionViewLayout.register(TimelineDecorationView.self, forDecorationViewOfKind: ProgramGuideGridLayout.ElementKind.timeline.rawValue) collectionView.collectionViewLayout.register(NowArrowDecorationView.self, forDecorationViewOfKind: ProgramGuideGridLayout.ElementKind.nowArrow.rawValue) collectionView.collectionViewLayout.register(NowLineDecorationView.self, forDecorationViewOfKind: ProgramGuideGridLayout.ElementKind.nowLine.rawValue) - + dailyModel.$state .sink { [weak self] state in self?.reloadData(for: state) } .store(in: &cancellables) - + model.$change .sink { [weak self] change in guard let self else { return } @@ -136,14 +135,14 @@ final class ProgramGuideGridViewController: UIViewController { } .store(in: &cancellables) } - + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - + navigationController?.setNavigationBarHidden(false, animated: animated) scrollToTarget(ScrollTarget(channel: model.selectedChannel, time: model.time), animated: false) } - + private func reloadData(for state: ProgramGuideDailyViewModel.State) { switch state { case let .failed(error: error): @@ -151,24 +150,21 @@ final class ProgramGuideGridViewController: UIViewController { case .content: if state.isLoading { emptyContentView.content = EmptyContentView(state: .loading) - } - else if state.isEmpty { + } else if state.isEmpty { emptyContentView.content = EmptyContentView(state: .empty(type: .generic), layout: constant(iOS: .standard, tvOS: .text)) - } - else { + } else { emptyContentView.content = nil } -#if os(tvOS) - if let channel = model.selectedChannel ?? model.channels.first, let section = state.sections.first(where: { $0 == channel }) ?? state.sections.first, - let currentProgram = state.items(for: section).compactMap(\.program).first(where: { $0.play_containsDate(model.date(for: model.time)) }) { - model.focusedProgramAndChannel = ProgramAndChannel(program: currentProgram, channel: channel) - } - else { - model.focusedProgramAndChannel = nil - } -#endif + #if os(tvOS) + if let channel = model.selectedChannel ?? model.channels.first, let section = state.sections.first(where: { $0 == channel }) ?? state.sections.first, + let currentProgram = state.items(for: section).compactMap(\.program).first(where: { $0.play_containsDate(model.date(for: model.time)) }) { + model.focusedProgramAndChannel = ProgramAndChannel(program: currentProgram, channel: channel) + } else { + model.focusedProgramAndChannel = nil + } + #endif } - + DispatchQueue.global(qos: .userInteractive).async { self.dataSource.apply(Self.snapshot(from: state), animatingDifferences: false) { if !state.isEmpty { @@ -189,30 +185,27 @@ private extension ProgramGuideGridViewController { guard let time else { return nil } return ProgramGuideGridLayout.xOffset(centeringDate: model.date(for: time), in: collectionView, day: model.day) } - + func yOffset(for channel: PlayChannel?) -> CGFloat? { guard let channel, let sectionIndex = dataSource.snapshot().sectionIdentifiers.firstIndex(of: channel) else { return nil } return ProgramGuideGridLayout.yOffset(forSectionIndex: sectionIndex, in: collectionView) } - + func offset(for target: ScrollTarget) -> CGPoint? { if let x = xOffset(for: target.time) { - return CGPoint(x: x, y: yOffset(for: target.channel) ?? collectionView.contentOffset.y) - } - else if let y = yOffset(for: target.channel) { - return CGPoint(x: collectionView.contentOffset.x, y: y) - } - else { - return nil + CGPoint(x: x, y: yOffset(for: target.channel) ?? collectionView.contentOffset.y) + } else if let y = yOffset(for: target.channel) { + CGPoint(x: collectionView.contentOffset.x, y: y) + } else { + nil } } - + func scrollToTarget(_ target: ScrollTarget?, animated: Bool) { if let target, let offset = offset(for: target) { collectionView.setContentOffset(offset, animated: animated) scrollTarget = nil - } - else { + } else { scrollTarget = target } } @@ -224,20 +217,20 @@ private extension ProgramGuideGridViewController { struct ScrollTarget { let channel: PlayChannel? let time: TimeInterval? - + init?(channel: PlayChannel?, time: TimeInterval?) { guard channel != nil || time != nil else { return nil } self.channel = channel self.time = time } - + init(channel: PlayChannel) { self.channel = channel - self.time = nil + time = nil } - + init(time: TimeInterval) { - self.channel = nil + channel = nil self.time = time } } @@ -247,30 +240,30 @@ private extension ProgramGuideGridViewController { extension ProgramGuideGridViewController: ContentInsets { var play_contentScrollViews: [UIScrollView]? { - return collectionView != nil ? [collectionView] : nil + collectionView != nil ? [collectionView] : nil } - + var play_paddingContentInsets: UIEdgeInsets { - return UIEdgeInsets(top: 0, left: 0, bottom: ProgramGuideGridLayout.verticalSpacing, right: 0) + UIEdgeInsets(top: 0, left: 0, bottom: ProgramGuideGridLayout.verticalSpacing, right: 0) } } extension ProgramGuideGridViewController: ProgramGuideChildViewController { var programGuideLayout: ProgramGuideLayout { - return .grid + .grid } - + var programGuideDailyViewModel: ProgramGuideDailyViewModel? { - return dailyModel + dailyModel } } #if os(iOS) -extension ProgramGuideGridViewController: ScrollableContent { - var play_scrollableView: UIScrollView? { - return collectionView + extension ProgramGuideGridViewController: ScrollableContent { + var play_scrollableView: UIScrollView? { + collectionView + } } -} #endif extension ProgramGuideGridViewController: UICollectionViewDelegate { @@ -281,64 +274,63 @@ extension ProgramGuideGridViewController: UICollectionViewDelegate { deselectItems(in: collectionView, animated: true) return } - -#if os(tvOS) - navigateToProgram(program, in: channel.wrappedValue) -#else - AnalyticsClickEvent.tvGuideOpenInfoBox(program: program, programGuideLayout: .grid).send() - // Deselection is managed here rather than in view appearance methods, as those are not called with the - // modal presentation we use. - let programViewController = ProgramView.viewController(for: program, channel: channel) - present(programViewController, animated: true) { - self.deselectItems(in: collectionView, animated: true) - } -#endif - } - -#if os(tvOS) - func collectionView(_ collectionView: UICollectionView, didUpdateFocusIn context: UICollectionViewFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) { - if let previouslyFocusedIndexPath = context.previouslyFocusedIndexPath, - let previouslyFocusedCell = collectionView.cellForItem(at: previouslyFocusedIndexPath) as? HostCollectionViewCell { - previouslyFocusedCell.isUIKitFocused = false - } - if let nextFocusedIndexPath = context.nextFocusedIndexPath { - if let nextFocusedCell = collectionView.cellForItem(at: nextFocusedIndexPath) as? HostCollectionViewCell { - nextFocusedCell.isUIKitFocused = true + + #if os(tvOS) + navigateToProgram(program, in: channel.wrappedValue) + #else + AnalyticsClickEvent.tvGuideOpenInfoBox(program: program, programGuideLayout: .grid).send() + // Deselection is managed here rather than in view appearance methods, as those are not called with the + // modal presentation we use. + let programViewController = ProgramView.viewController(for: program, channel: channel) + present(programViewController, animated: true) { + self.deselectItems(in: collectionView, animated: true) } - - let snapshot = dataSource.snapshot() - let channel = snapshot.sectionIdentifiers[nextFocusedIndexPath.section] - model.selectedChannel = channel - let program = snapshot.itemIdentifiers(inSection: channel)[nextFocusedIndexPath.row].program - if let program { - model.focusedProgramAndChannel = ProgramAndChannel(program: program, channel: channel) + #endif + } + + #if os(tvOS) + func collectionView(_ collectionView: UICollectionView, didUpdateFocusIn context: UICollectionViewFocusUpdateContext, with _: UIFocusAnimationCoordinator) { + if let previouslyFocusedIndexPath = context.previouslyFocusedIndexPath, + let previouslyFocusedCell = collectionView.cellForItem(at: previouslyFocusedIndexPath) as? HostCollectionViewCell { + previouslyFocusedCell.isUIKitFocused = false } - else { - model.focusedProgramAndChannel = nil + if let nextFocusedIndexPath = context.nextFocusedIndexPath { + if let nextFocusedCell = collectionView.cellForItem(at: nextFocusedIndexPath) as? HostCollectionViewCell { + nextFocusedCell.isUIKitFocused = true + } + + let snapshot = dataSource.snapshot() + let channel = snapshot.sectionIdentifiers[nextFocusedIndexPath.section] + model.selectedChannel = channel + let program = snapshot.itemIdentifiers(inSection: channel)[nextFocusedIndexPath.row].program + if let program { + model.focusedProgramAndChannel = ProgramAndChannel(program: program, channel: channel) + } else { + model.focusedProgramAndChannel = nil + } } } - } -#endif + #endif } extension ProgramGuideGridViewController: UIScrollViewDelegate { - func scrollViewDidScroll(_ scrollView: UIScrollView) { + func scrollViewDidScroll(_: UIScrollView) { let sectionIndex = ProgramGuideGridLayout.sectionIndex(atYOffset: collectionView.contentOffset.y, in: collectionView) guard let channel = dataSource.snapshot().sectionIdentifiers[safeIndex: sectionIndex] else { return } model.selectedChannel = channel - + guard let date = ProgramGuideGridLayout.date(centeredAtXOffset: collectionView.contentOffset.x, in: collectionView, day: dailyModel.day) else { return } let time = date.timeIntervalSince(dailyModel.day.date) model.didScrollToTime(time) } - -#if os(iOS) - // The system default behavior does not lead to correct results when large titles are displayed. Override. - func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { - scrollView.play_scrollToTop(animated: true) - return false - } -#endif + + #if os(iOS) + // The system default behavior does not lead to correct results when large titles are displayed. Override. + func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { + scrollView.play_scrollToTop(animated: true) + return false + } + #endif } // MARK: Views @@ -346,7 +338,7 @@ extension ProgramGuideGridViewController: UIScrollViewDelegate { private extension ProgramGuideGridViewController { struct ItemCell: View { let item: ProgramGuideDailyViewModel.Item - + var body: some View { switch item.wrappedValue { case let .program(program): @@ -358,22 +350,22 @@ private extension ProgramGuideGridViewController { } } } - + final class TimelineDecorationView: HostSupplementaryView { override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) { guard let timelineAttributes = layoutAttributes as? TimelineLayoutAttributes else { return } content = TimelineView(dateInterval: timelineAttributes.dateInterval) } } - + final class NowArrowDecorationView: HostSupplementaryView { - override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) { + override func apply(_: UICollectionViewLayoutAttributes) { content = NowArrowView() } } final class NowLineDecorationView: HostSupplementaryView { - override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) { + override func apply(_: UICollectionViewLayoutAttributes) { content = NowLineView() } } diff --git a/Application/Sources/ProgramGuide/ProgramGuideHeaderView.swift b/Application/Sources/ProgramGuide/ProgramGuideHeaderView.swift index 2a4f06d9e..b2e4a0d2e 100644 --- a/Application/Sources/ProgramGuide/ProgramGuideHeaderView.swift +++ b/Application/Sources/ProgramGuide/ProgramGuideHeaderView.swift @@ -4,15 +4,15 @@ // License information is available from the LICENSE file. // -import SwiftUI import SRGDataProviderModel +import SwiftUI // MARK: Contract #if os(iOS) -@objc protocol ProgramGuideHeaderViewActions: AnyObject { - func openCalendar() -} + @objc protocol ProgramGuideHeaderViewActions: AnyObject { + func openCalendar() + } #endif // MARK: View @@ -21,43 +21,43 @@ import SRGDataProviderModel struct ProgramGuideHeaderView: View { @ObservedObject var model: ProgramGuideViewModel let layout: ProgramGuideLayout - + var body: some View { -#if os(tvOS) - ZStack { - ProgramPreview(data: model.focusedProgramAndChannel) - .accessibilityHidden(true) - NavigationBar(model: model) - .focusable() - .padding(.horizontal, 56) - .padding(.vertical, 40 + ProgramGuideGridLayout.timelineHeight) - .frame(maxHeight: .infinity, alignment: .bottom) - } -#else - VStack(spacing: 0) { - NavigationBar(model: model) - Spacer(minLength: 20) - if layout == .list { - ChannelSelector(model: model) + #if os(tvOS) + ZStack { + ProgramPreview(data: model.focusedProgramAndChannel) + .accessibilityHidden(true) + NavigationBar(model: model) + .focusable() + .padding(.horizontal, 56) + .padding(.vertical, 40 + ProgramGuideGridLayout.timelineHeight) + .frame(maxHeight: .infinity, alignment: .bottom) } - } - .padding(.horizontal, 10) - .padding(.vertical, 10) -#endif + #else + VStack(spacing: 0) { + NavigationBar(model: model) + Spacer(minLength: 20) + if layout == .list { + ChannelSelector(model: model) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 10) + #endif } - + /// Behavior: h-exp, v-hug private struct NavigationBar: View { @ObservedObject var model: ProgramGuideViewModel @Environment(\.uiHorizontalSizeClass) private var horizontalSizeClass - + private static let itemHeight: CGFloat = constant(iOS: 40, tvOS: 70) private static let spacing: CGFloat = constant(iOS: 42, tvOS: 40) - + private var direction: StackDirection { - return (horizontalSizeClass == .compact) ? .vertical : .horizontal + (horizontalSizeClass == .compact) ? .vertical : .horizontal } - + var body: some View { Group { if direction == .vertical { @@ -67,8 +67,7 @@ struct ProgramGuideHeaderView: View { DayNavigationBar(model: model) .frame(height: Self.itemHeight) } - } - else { + } else { HStack(spacing: Self.spacing) { DayNavigationBar(model: model) .frame(maxWidth: constant(iOS: 400, tvOS: 750)) @@ -80,19 +79,19 @@ struct ProgramGuideHeaderView: View { } } } - + /// Behavior: h-exp, v-exp private struct DaySelector: View { @ObservedObject var model: ProgramGuideViewModel @FirstResponder private var firstResponder - + var body: some View { HStack(spacing: constant(iOS: 10, tvOS: 40)) { -#if os(iOS) - ExpandingButton(icon: .calendar, label: NSLocalizedString("Calendar", comment: "Calendar button in program guide")) { - firstResponder.sendAction(#selector(ProgramGuideHeaderViewActions.openCalendar)) - } -#endif + #if os(iOS) + ExpandingButton(icon: .calendar, label: NSLocalizedString("Calendar", comment: "Calendar button in program guide")) { + firstResponder.sendAction(#selector(ProgramGuideHeaderViewActions.openCalendar)) + } + #endif ExpandingButton(label: NSLocalizedString("Now", comment: "Now button in program guide")) { AnalyticsClickEvent.tvGuideNow().send() model.switchToNow() @@ -105,14 +104,14 @@ struct ProgramGuideHeaderView: View { .responderChain(from: firstResponder) } } - + /// Behavior: h-exp, v-exp private struct DayNavigationBar: View { @ObservedObject var model: ProgramGuideViewModel @FirstResponder private var firstResponder - + private static let buttonWidth: CGFloat = constant(iOS: 43, tvOS: 70) - + var body: some View { HStack(spacing: constant(iOS: 10, tvOS: 40)) { ExpandingButton(icon: .chevronPrevious, accessibilityLabel: PlaySRGAccessibilityLocalizedString("Previous day", comment: "Previous day button label in program guide")) { @@ -120,15 +119,15 @@ struct ProgramGuideHeaderView: View { model.switchToPreviousDay() } .frame(width: Self.buttonWidth) - -#if os(iOS) - Button(action: action) { + + #if os(iOS) + Button(action: action) { + DateView(model: model) + } + #else DateView(model: model) - } -#else - DateView(model: model) -#endif - + #endif + ExpandingButton(icon: .chevronNext, accessibilityLabel: PlaySRGAccessibilityLocalizedString("Next day", comment: "Next day button label in program guide")) { AnalyticsClickEvent.tvGuideNextDay().send() model.switchToNextDay() @@ -137,11 +136,11 @@ struct ProgramGuideHeaderView: View { } .responderChain(from: firstResponder) } - + /// Behavior: h-exp, v-hug private struct DateView: View { @ObservedObject var model: ProgramGuideViewModel - + var body: some View { Text(model.dateString) .srgFont(.H2) @@ -150,65 +149,64 @@ struct ProgramGuideHeaderView: View { .frame(maxWidth: .infinity) } } - -#if os(iOS) - private func action() { - firstResponder.sendAction(#selector(ProgramGuideHeaderViewActions.openCalendar)) - } -#endif + + #if os(iOS) + private func action() { + firstResponder.sendAction(#selector(ProgramGuideHeaderViewActions.openCalendar)) + } + #endif } - -#if os(iOS) - /// Behavior: h-exp, v-hug - private struct ChannelSelector: View { - @ObservedObject var model: ProgramGuideViewModel - - var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - ScrollViewReader { proxy in - HStack(spacing: 10) { - if !model.channels.isEmpty { - ForEach(model.channels, id: \.wrappedValue.uid) { channel in - ChannelButton(channel: channel.wrappedValue) { - model.selectedChannel = channel + + #if os(iOS) + /// Behavior: h-exp, v-hug + private struct ChannelSelector: View { + @ObservedObject var model: ProgramGuideViewModel + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + ScrollViewReader { proxy in + HStack(spacing: 10) { + if !model.channels.isEmpty { + ForEach(model.channels, id: \.wrappedValue.uid) { channel in + ChannelButton(channel: channel.wrappedValue) { + model.selectedChannel = channel + } + .environment(\.isSelected, channel == model.selectedChannel) } - .environment(\.isSelected, channel == model.selectedChannel) - } - .onAppear { - if let selectedChannel = model.selectedChannel { - proxy.scrollTo(selectedChannel.wrappedValue.uid) + .onAppear { + if let selectedChannel = model.selectedChannel { + proxy.scrollTo(selectedChannel.wrappedValue.uid) + } + } + } else { + ForEach(0 ..< 2) { _ in + ChannelButton(channel: nil, action: {}) } - } - } - else { - ForEach(0..<2) { _ in - ChannelButton(channel: nil, action: {}) } } } } + .frame(height: 50) + .padding(.bottom, 6) } - .frame(height: 50) - .padding(.bottom, 6) } - } -#endif + #endif } // MARK: Size enum ProgramGuideHeaderViewSize { static func height(for layout: ProgramGuideLayout, horizontalSizeClass: UIUserInterfaceSizeClass) -> CGFloat { -#if os(iOS) - switch layout { - case .grid: - return (horizontalSizeClass == .compact) ? 160 : 80 - case .list: - return (horizontalSizeClass == .compact) ? 216 : 136 - } -#else - return ApplicationConfiguration.shared.tvGuideOtherBouquets.isEmpty ? 760 : 650 -#endif + #if os(iOS) + switch layout { + case .grid: + return (horizontalSizeClass == .compact) ? 160 : 80 + case .list: + return (horizontalSizeClass == .compact) ? 216 : 136 + } + #else + return ApplicationConfiguration.shared.tvGuideOtherBouquets.isEmpty ? 760 : 650 + #endif } } @@ -216,26 +214,26 @@ enum ProgramGuideHeaderViewSize { struct ProgramGuideHeaderView_Previews: PreviewProvider { static var previews: some View { -#if os(tvOS) - ProgramGuideHeaderView(model: ProgramGuideViewModel(date: Date()), layout: .grid) - .previewLayout(.fixed(width: 1920, height: 600)) -#else - ProgramGuideHeaderView(model: ProgramGuideViewModel(date: Date()), layout: .grid) - .previewLayout(.fixed(width: 1000, height: ProgramGuideHeaderViewSize.height(for: .grid, horizontalSizeClass: .regular))) - .environment(\.horizontalSizeClass, .regular) - .previewDisplayName("Grid, regular") - ProgramGuideHeaderView(model: ProgramGuideViewModel(date: Date()), layout: .grid) - .previewLayout(.fixed(width: 375, height: ProgramGuideHeaderViewSize.height(for: .grid, horizontalSizeClass: .compact))) - .environment(\.horizontalSizeClass, .compact) - .previewDisplayName("Grid, compact") - ProgramGuideHeaderView(model: ProgramGuideViewModel(date: Date()), layout: .list) - .previewLayout(.fixed(width: 1000, height: ProgramGuideHeaderViewSize.height(for: .list, horizontalSizeClass: .regular))) - .environment(\.horizontalSizeClass, .regular) - .previewDisplayName("List, regular") - ProgramGuideHeaderView(model: ProgramGuideViewModel(date: Date()), layout: .list) - .previewLayout(.fixed(width: 375, height: ProgramGuideHeaderViewSize.height(for: .list, horizontalSizeClass: .compact))) - .environment(\.horizontalSizeClass, .compact) - .previewDisplayName("List, compact") -#endif + #if os(tvOS) + ProgramGuideHeaderView(model: ProgramGuideViewModel(date: Date()), layout: .grid) + .previewLayout(.fixed(width: 1920, height: 600)) + #else + ProgramGuideHeaderView(model: ProgramGuideViewModel(date: Date()), layout: .grid) + .previewLayout(.fixed(width: 1000, height: ProgramGuideHeaderViewSize.height(for: .grid, horizontalSizeClass: .regular))) + .environment(\.horizontalSizeClass, .regular) + .previewDisplayName("Grid, regular") + ProgramGuideHeaderView(model: ProgramGuideViewModel(date: Date()), layout: .grid) + .previewLayout(.fixed(width: 375, height: ProgramGuideHeaderViewSize.height(for: .grid, horizontalSizeClass: .compact))) + .environment(\.horizontalSizeClass, .compact) + .previewDisplayName("Grid, compact") + ProgramGuideHeaderView(model: ProgramGuideViewModel(date: Date()), layout: .list) + .previewLayout(.fixed(width: 1000, height: ProgramGuideHeaderViewSize.height(for: .list, horizontalSizeClass: .regular))) + .environment(\.horizontalSizeClass, .regular) + .previewDisplayName("List, regular") + ProgramGuideHeaderView(model: ProgramGuideViewModel(date: Date()), layout: .list) + .previewLayout(.fixed(width: 375, height: ProgramGuideHeaderViewSize.height(for: .list, horizontalSizeClass: .compact))) + .environment(\.horizontalSizeClass, .compact) + .previewDisplayName("List, compact") + #endif } } diff --git a/Application/Sources/ProgramGuide/ProgramGuideListViewController.swift b/Application/Sources/ProgramGuide/ProgramGuideListViewController.swift index d363bb413..9b0235c19 100644 --- a/Application/Sources/ProgramGuide/ProgramGuideListViewController.swift +++ b/Application/Sources/ProgramGuide/ProgramGuideListViewController.swift @@ -12,9 +12,9 @@ import UIKit final class ProgramGuideListViewController: UIViewController { private let model: ProgramGuideViewModel private let pageViewController: UIPageViewController - + private var cancellables = Set() - + init(model: ProgramGuideViewModel, dailyModel: ProgramGuideDailyViewModel?) { self.model = model pageViewController = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: [ @@ -22,30 +22,31 @@ final class ProgramGuideListViewController: UIViewController { ]) super.init(nibName: nil, bundle: nil) addChild(pageViewController) - + let dailyViewController = ProgramGuideDailyViewController(day: model.day, programGuideModel: model, programGuideDailyModel: dailyModel) pageViewController.setViewControllers([dailyViewController], direction: .forward, animated: false) } - - required init?(coder: NSCoder) { + + @available(*, unavailable) + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func loadView() { let view = UIView(frame: UIScreen.main.bounds) view.backgroundColor = .srgGray16 self.view = view } - + override func viewDidLoad() { super.viewDidLoad() - + pageViewController.dataSource = self pageViewController.delegate = self - + if let pageView = pageViewController.view { view.addSubview(pageView) - + pageView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ pageView.topAnchor.constraint(equalTo: view.topAnchor), @@ -55,7 +56,7 @@ final class ProgramGuideListViewController: UIViewController { ]) } pageViewController.didMove(toParent: self) - + model.$change .sink { [weak self] change in switch change { @@ -67,12 +68,12 @@ final class ProgramGuideListViewController: UIViewController { } .store(in: &cancellables) } - + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationController?.setNavigationBarHidden(false, animated: animated) } - + private func switchToDay(_ day: SRGDay) { guard let currentViewController = pageViewController.viewControllers?.first as? ProgramGuideDailyViewController, currentViewController.day != day else { return } @@ -88,45 +89,44 @@ final class ProgramGuideListViewController: UIViewController { extension ProgramGuideListViewController: ProgramGuideChildViewController { var programGuideLayout: ProgramGuideLayout { - return .list + .list } - + var programGuideDailyViewModel: ProgramGuideDailyViewModel? { if let currentViewController = pageViewController.viewControllers?.first as? ProgramGuideDailyViewController { - return currentViewController.programGuideDailyViewModel - } - else { - return nil + currentViewController.programGuideDailyViewModel + } else { + nil } } } extension ProgramGuideListViewController: ScrollableContentContainer { var play_scrollableChildViewController: UIViewController? { - return pageViewController.viewControllers?.first + pageViewController.viewControllers?.first } } extension ProgramGuideListViewController: UIPageViewControllerDataSource { - func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { + func pageViewController(_: UIPageViewController, viewControllerBefore _: UIViewController) -> UIViewController? { let previousDay = SRGDay(byAddingDays: -1, months: 0, years: 0, to: model.day) return ProgramGuideDailyViewController(day: previousDay, programGuideModel: model) } - - func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { + + func pageViewController(_: UIPageViewController, viewControllerAfter _: UIViewController) -> UIViewController? { let nextDay = SRGDay(byAddingDays: 1, months: 0, years: 0, to: model.day) return ProgramGuideDailyViewController(day: nextDay, programGuideModel: model) } } extension ProgramGuideListViewController: UIPageViewControllerDelegate { - func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) { + func pageViewController(_: UIPageViewController, willTransitionTo _: [UIViewController]) { model.isHeaderUserInteractionEnabled = false } - - func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { + + func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating _: Bool, previousViewControllers _: [UIViewController], transitionCompleted completed: Bool) { model.isHeaderUserInteractionEnabled = true - + if completed, let currentViewController = pageViewController.viewControllers?.first as? ProgramGuideDailyViewController { model.switchToDay(currentViewController.day) play_setNeedsScrollableViewUpdate() diff --git a/Application/Sources/ProgramGuide/ProgramGuideViewController.swift b/Application/Sources/ProgramGuide/ProgramGuideViewController.swift index de8588220..8a94a65d1 100644 --- a/Application/Sources/ProgramGuide/ProgramGuideViewController.swift +++ b/Application/Sources/ProgramGuide/ProgramGuideViewController.swift @@ -11,39 +11,40 @@ import UIKit final class ProgramGuideViewController: UIViewController { private let model: ProgramGuideViewModel - + private weak var headerHostView: UIView! private weak var headerView: HostView! private weak var headerHostHeightConstraint: NSLayoutConstraint! private weak var headerHeightConstraint: NSLayoutConstraint! private weak var headerTopConstraint: NSLayoutConstraint! - - private var _layout: ProgramGuideLayout = .grid // Pseudo ivar to implement animated and non-animated setters + + private var _layout: ProgramGuideLayout = .grid // Pseudo ivar to implement animated and non-animated setters private var cancellables = Set() - + private static let transitionDuration: TimeInterval = 0.4 - + init(date: Date? = nil) { model = ProgramGuideViewModel(date: date ?? Date()) super.init(nibName: nil, bundle: nil) title = NSLocalizedString("TV guide", comment: "TV program guide view title") } - - required init?(coder: NSCoder) { + + @available(*, unavailable) + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func viewDidLoad() { super.viewDidLoad() - + view.backgroundColor = .srgGray16 - + let headerHostView = UIView() view.addSubview(headerHostView) self.headerHostView = headerHostView - - let headerHostHeightConstraint = headerHostView.heightAnchor.constraint(equalToConstant: 0 /* set in transition(to:traitCollection:animated:) */) - + + let headerHostHeightConstraint = headerHostView.heightAnchor.constraint(equalToConstant: 0 /* set in transition(to:traitCollection:animated:) */ ) + headerHostView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ headerHostView.topAnchor.constraint(equalTo: constant(iOS: view.safeAreaLayoutGuide.topAnchor, tvOS: view.topAnchor)), @@ -52,14 +53,14 @@ final class ProgramGuideViewController: UIViewController { headerHostView.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) self.headerHostHeightConstraint = headerHostHeightConstraint - + let headerView = HostView(frame: .zero) headerHostView.addSubview(headerView) self.headerView = headerView - + let headerTopConstraint = headerView.topAnchor.constraint(equalTo: headerHostView.topAnchor) - let headerHeightConstraint = headerView.heightAnchor.constraint(equalToConstant: 0 /* set in transition(to:traitCollection:animated:) */) - + let headerHeightConstraint = headerView.heightAnchor.constraint(equalToConstant: 0 /* set in transition(to:traitCollection:animated:) */ ) + headerView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ headerTopConstraint, @@ -69,75 +70,75 @@ final class ProgramGuideViewController: UIViewController { ]) self.headerTopConstraint = headerTopConstraint self.headerHeightConstraint = headerHeightConstraint - + _layout = Self.layout(for: traitCollection) transition(to: _layout, traitCollection: traitCollection, animated: false) - -#if os(iOS) - model.$isHeaderUserInteractionEnabled - .sink { isHeaderUserInteractionEnabled in - headerView.isUserInteractionEnabled = isHeaderUserInteractionEnabled - } - .store(in: &cancellables) - - navigationItem.largeTitleDisplayMode = .always - updateNavigationBar() -#endif + + #if os(iOS) + model.$isHeaderUserInteractionEnabled + .sink { isHeaderUserInteractionEnabled in + headerView.isUserInteractionEnabled = isHeaderUserInteractionEnabled + } + .store(in: &cancellables) + + navigationItem.largeTitleDisplayMode = .always + updateNavigationBar() + #endif } -#if os(iOS) - override func willTransition(to newCollection: UITraitCollection, with coordinator: UIViewControllerTransitionCoordinator) { - super.willTransition(to: newCollection, with: coordinator) - coordinator.animate { _ in - self.transition(to: Self.layout(for: newCollection), traitCollection: newCollection, animated: false) - } completion: { _ in + #if os(iOS) + override func willTransition(to newCollection: UITraitCollection, with coordinator: UIViewControllerTransitionCoordinator) { + super.willTransition(to: newCollection, with: coordinator) + coordinator.animate { _ in + self.transition(to: Self.layout(for: newCollection), traitCollection: newCollection, animated: false) + } completion: { _ in + } } - } - - private func updateNavigationBar() { - let isGrid = (layout == .grid) - let layoutBarButtonItem = UIBarButtonItem( - image: UIImage(resource: isGrid ? .layoutGridOn : .layoutListOn), - style: .plain, - target: self, - action: #selector(toggleLayout(_:)) - ) - layoutBarButtonItem.accessibilityLabel = isGrid - ? PlaySRGAccessibilityLocalizedString("Display list", comment: "Button to display the TV guide as a list") - : PlaySRGAccessibilityLocalizedString("Display grid", comment: "Button to display the TV guide as a grid") - navigationItem.rightBarButtonItem = layoutBarButtonItem - } - - @objc private func toggleLayout(_ sender: AnyObject) { - func toggle(to layout: ProgramGuideLayout) { - AnalyticsClickEvent.tvGuideChangeLayout(to: layout).send() - setLayout(layout, animated: true) - ApplicationSettingSetProgramGuideRecentlyUsedLayout(layout) + + private func updateNavigationBar() { + let isGrid = (layout == .grid) + let layoutBarButtonItem = UIBarButtonItem( + image: UIImage(resource: isGrid ? .layoutGridOn : .layoutListOn), + style: .plain, + target: self, + action: #selector(toggleLayout(_:)) + ) + layoutBarButtonItem.accessibilityLabel = isGrid + ? PlaySRGAccessibilityLocalizedString("Display list", comment: "Button to display the TV guide as a list") + : PlaySRGAccessibilityLocalizedString("Display grid", comment: "Button to display the TV guide as a grid") + navigationItem.rightBarButtonItem = layoutBarButtonItem } - - switch layout { - case .list: - toggle(to: .grid) - case .grid: - toggle(to: .list) + + @objc private func toggleLayout(_: AnyObject) { + func toggle(to layout: ProgramGuideLayout) { + AnalyticsClickEvent.tvGuideChangeLayout(to: layout).send() + setLayout(layout, animated: true) + ApplicationSettingSetProgramGuideRecentlyUsedLayout(layout) + } + + switch layout { + case .list: + toggle(to: .grid) + case .grid: + toggle(to: .list) + } + updateNavigationBar() } - updateNavigationBar() - } -#endif - + #endif + private static func layout(for traitCollection: UITraitCollection) -> ProgramGuideLayout { - return constant(iOS: ApplicationSettingProgramGuideRecentlyUsedLayout(traitCollection.horizontalSizeClass == .compact), tvOS: .grid) + constant(iOS: ApplicationSettingProgramGuideRecentlyUsedLayout(traitCollection.horizontalSizeClass == .compact), tvOS: .grid) } - -#if os(tvOS) - override var preferredFocusEnvironments: [UIFocusEnvironment] { - return [headerView] - } - - @objc private func menuPressed(_ gestureRecognizer: UIGestureRecognizer) { - setNeedsFocusUpdate() - } -#endif + + #if os(tvOS) + override var preferredFocusEnvironments: [UIFocusEnvironment] { + [headerView] + } + + @objc private func menuPressed(_: UIGestureRecognizer) { + setNeedsFocusUpdate() + } + #endif } // MARK: Layout @@ -148,26 +149,26 @@ extension ProgramGuideViewController { _layout = layout transition(to: layout, traitCollection: traitCollection, animated: animated) } - + private var layout: ProgramGuideLayout { get { - return _layout + _layout } set { setLayout(newValue, animated: false) } } - + private func addProgramGuideChild(_ viewController: UIViewController) { addChild(viewController) - + let childView = viewController.view! -#if os(tvOS) - view.addSubview(childView) -#else - view.insertSubview(childView, at: 0) -#endif - + #if os(tvOS) + view.addSubview(childView) + #else + view.insertSubview(childView, at: 0) + #endif + childView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ childView.topAnchor.constraint(equalTo: headerHostView.bottomAnchor, constant: constant(iOS: 0, tvOS: -ProgramGuideGridLayout.timelineHeight)), @@ -175,28 +176,28 @@ extension ProgramGuideViewController { childView.leadingAnchor.constraint(equalTo: view.leadingAnchor), childView.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) - -#if os(tvOS) - let menuGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(menuPressed(_:))) - menuGestureRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.menu.rawValue)] - childView.addGestureRecognizer(menuGestureRecognizer) -#endif + + #if os(tvOS) + let menuGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(menuPressed(_:))) + menuGestureRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.menu.rawValue)] + childView.addGestureRecognizer(menuGestureRecognizer) + #endif } - + private func transition(to layout: ProgramGuideLayout, traitCollection: UITraitCollection, animated: Bool) { let headerHeight = ProgramGuideHeaderViewSize.height(for: layout, horizontalSizeClass: traitCollection.horizontalSizeClass) headerView.content = ProgramGuideHeaderView(model: model, layout: layout) headerHeightConstraint.constant = headerHeight - + // Use the last child since we can have two children when snapping layouts and we want to check the // most recent desired layout. if let previousViewController = children.last as? UIViewController & ProgramGuideChildViewController { if previousViewController.programGuideLayout != layout { previousViewController.willMove(toParent: nil) - + let viewController = viewController(for: layout, dailyModel: previousViewController.programGuideDailyViewModel) addProgramGuideChild(viewController) - + if animated { viewController.view.alpha = 0 view.layoutIfNeeded() @@ -210,61 +211,57 @@ extension ProgramGuideViewController { previousViewController.removeFromParent() viewController.didMove(toParent: self) self.play_setNeedsScrollableViewUpdate() -#if os(iOS) - self.model.isHeaderUserInteractionEnabled = true -#endif + #if os(iOS) + self.model.isHeaderUserInteractionEnabled = true + #endif } - } - else { + } else { headerHostHeightConstraint.constant = headerHeight previousViewController.view.removeFromSuperview() previousViewController.removeFromParent() viewController.didMove(toParent: self) play_setNeedsScrollableViewUpdate() -#if os(iOS) - model.isHeaderUserInteractionEnabled = true -#endif + #if os(iOS) + model.isHeaderUserInteractionEnabled = true + #endif } - } - else { + } else { if animated { view.layoutIfNeeded() UIView.animate(withDuration: Self.transitionDuration) { self.headerHostHeightConstraint.constant = headerHeight self.view.layoutIfNeeded() } completion: { _ in -#if os(iOS) - self.model.isHeaderUserInteractionEnabled = true -#endif + #if os(iOS) + self.model.isHeaderUserInteractionEnabled = true + #endif } - } - else { + } else { headerHostHeightConstraint.constant = headerHeight -#if os(iOS) - model.isHeaderUserInteractionEnabled = true -#endif + #if os(iOS) + model.isHeaderUserInteractionEnabled = true + #endif } } - } - else { + } else { let viewController = viewController(for: layout, dailyModel: nil) addProgramGuideChild(viewController) viewController.didMove(toParent: self) headerHostHeightConstraint.constant = headerHeight -#if os(iOS) - model.isHeaderUserInteractionEnabled = true -#endif + #if os(iOS) + model.isHeaderUserInteractionEnabled = true + #endif } } - + private func viewController(for layout: ProgramGuideLayout, dailyModel: ProgramGuideDailyViewModel?) -> UIViewController { switch layout { case .list: -#if os(iOS) - return ProgramGuideListViewController(model: model, dailyModel: dailyModel) -#else - return ProgramGuideGridViewController(model: model, dailyModel: dailyModel) -#endif + #if os(iOS) + return ProgramGuideListViewController(model: model, dailyModel: dailyModel) + #else + return ProgramGuideGridViewController(model: model, dailyModel: dailyModel) + #endif case .grid: return ProgramGuideGridViewController(model: model, dailyModel: dailyModel) } @@ -274,39 +271,38 @@ extension ProgramGuideViewController { // MARK: Protocols #if os(iOS) -extension ProgramGuideViewController: Oriented { -} + extension ProgramGuideViewController: Oriented {} #endif extension ProgramGuideViewController: ScrollableContentContainer { var play_scrollableChildViewController: UIViewController? { - return children.first + children.first } - + func play_contentOffsetDidChange(inScrollableView scrollView: UIScrollView) { headerTopConstraint.constant = max(-scrollView.contentOffset.y - scrollView.adjustedContentInset.top, 0) } } #if os(iOS) -extension ProgramGuideViewController: ProgramGuideHeaderViewActions { - func openCalendar() { - let calendarViewController = ProgramGuideCalendarViewController(model: model) - present(calendarViewController, animated: true) + extension ProgramGuideViewController: ProgramGuideHeaderViewActions { + func openCalendar() { + let calendarViewController = ProgramGuideCalendarViewController(model: model) + present(calendarViewController, animated: true) + } } -} #endif extension ProgramGuideViewController: SRGAnalyticsViewTracking { var srg_pageViewTitle: String { - return AnalyticsPageTitle.programGuide.rawValue + AnalyticsPageTitle.programGuide.rawValue } - + var srg_pageViewType: String { - return AnalyticsPageType.overview.rawValue + AnalyticsPageType.overview.rawValue } - + var srg_pageViewLevels: [String]? { - return [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.video.rawValue] + [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.video.rawValue] } } diff --git a/Application/Sources/ProgramGuide/ProgramGuideViewModel.swift b/Application/Sources/ProgramGuide/ProgramGuideViewModel.swift index 6334db295..c76c05ee9 100644 --- a/Application/Sources/ProgramGuide/ProgramGuideViewModel.swift +++ b/Application/Sources/ProgramGuide/ProgramGuideViewModel.swift @@ -5,45 +5,45 @@ // import Combine -import SRGDataProviderCombine import Foundation +import SRGDataProviderCombine // MARK: View model final class ProgramGuideViewModel: ObservableObject { @Published private(set) var data: Data = .empty - + /// Only significant changes are published. Noisy changes (e.g. because of scrolling) are not published. @Published private(set) var change: Change = .none - -#if os(iOS) - @Published var isHeaderUserInteractionEnabled = true -#else - @Published var focusedProgramAndChannel: ProgramAndChannel? -#endif - + + #if os(iOS) + @Published var isHeaderUserInteractionEnabled = true + #else + @Published var focusedProgramAndChannel: ProgramAndChannel? + #endif + private(set) var day: SRGDay - private(set) var time: TimeInterval // Position in day (distance from midnight) - + private(set) var time: TimeInterval // Position in day (distance from midnight) + static func time(from date: Date, relativeTo day: SRGDay) -> TimeInterval { - return date.timeIntervalSince(day.date) + date.timeIntervalSince(day.date) } - + var channels: [PlayChannel] { - return data.channels + data.channels } - + var mainPartyChannels: [PlayChannel] { - return data.mainPartyChannels + data.mainPartyChannels } - + var otherPartyChannels: [PlayChannel] { - return data.otherPartyChannels + data.otherPartyChannels } - + var selectedChannel: PlayChannel? { get { - return data.selectedChannel + data.selectedChannel } set { if let newValue, channels.contains(newValue), newValue != data.selectedChannel { @@ -52,71 +52,69 @@ final class ProgramGuideViewModel: ObservableObject { } } } - + func date(for time: TimeInterval) -> Date { - return day.date.addingTimeInterval(time) + day.date.addingTimeInterval(time) } - + var dateString: String { - return DateFormatter.play_relativeFullDate.string(from: day.date).capitalizedFirstLetter + DateFormatter.play_relativeFullDate.string(from: day.date).capitalizedFirstLetter } - + init(date: Date) { let initialDay = SRGDay(from: date) day = initialDay time = Self.time(from: date, relativeTo: initialDay) - + Publishers.PublishAndRepeat(onOutputFrom: ApplicationSignal.wokenUp()) { [weak self] in - return Self.data(for: initialDay, from: self?.data ?? .empty) + Self.data(for: initialDay, from: self?.data ?? .empty) } .receive(on: DispatchQueue.main) .assign(to: &$data) } - + private func switchToDay(_ day: SRGDay, atTime time: TimeInterval?) { let previousDay = self.day let previousTime = self.time - + self.day = day self.time = time ?? self.time - - if self.day != previousDay && self.time != previousTime { + + if self.day != previousDay, self.time != previousTime { change = .dayAndTime(day: self.day, time: self.time) - } - else if self.day != previousDay { + } else if self.day != previousDay { change = .day(self.day) - } - else if self.time != previousTime { + } else if self.time != previousTime { change = .time(self.time) } } - + private func switchToDate(_ date: Date) { let day = SRGDay(from: date) switchToDay(day, atTime: Self.time(from: date, relativeTo: day)) } - + func switchToDay(_ day: SRGDay) { switchToDay(day, atTime: nil) } - + func switchToPreviousDay() { switchToDay(SRGDay(byAddingDays: -1, months: 0, years: 0, to: day)) } - + func switchToNextDay() { switchToDay(SRGDay(byAddingDays: 1, months: 0, years: 0, to: day)) } - + func switchToTonight() { let date = Calendar.srgDefault.date(bySettingHour: 20, minute: 30, second: 0, of: Date())! switchToDate(date) } - + func switchToNow() { switchToDate(Date()) } - + func didScrollToTime(_ time: TimeInterval) { self.time = time } @@ -129,16 +127,16 @@ extension ProgramGuideViewModel { let mainPartyChannels: [PlayChannel] let otherPartyChannels: [PlayChannel] let selectedChannel: PlayChannel? - + static var empty: Self { - return Self(mainPartyChannels: [], otherPartyChannels: [], selectedChannel: nil) + Self(mainPartyChannels: [], otherPartyChannels: [], selectedChannel: nil) } - + var channels: [PlayChannel] { - return mainPartyChannels + otherPartyChannels + mainPartyChannels + otherPartyChannels } } - + enum Change { case none case day(SRGDay) @@ -153,25 +151,24 @@ extension ProgramGuideViewModel { private extension ProgramGuideViewModel { static func matchingChannel(_ channel: PlayChannel?, in channels: [PlayChannel]) -> PlayChannel? { if let channel, channels.contains(channel) { - return channel - } - else { - return channels.first + channel + } else { + channels.first } } - + // TODO: Once an IL request is available to get the channel list without any day, use this request and // remove the day parameter. - static func channels(for vendor: SRGVendor, mainProvider: Bool, day: SRGDay) -> AnyPublisher<[PlayChannel], Error> { - return SRGDataProvider.current!.tvProgramsPublisher(day: day, mainProvider: mainProvider, minimal: true) + static func channels(for _: SRGVendor, mainProvider: Bool, day: SRGDay) -> AnyPublisher<[PlayChannel], Error> { + SRGDataProvider.current!.tvProgramsPublisher(day: day, mainProvider: mainProvider, minimal: true) .map { $0.map(\.channel) } .eraseToAnyPublisher() } - + static func data(for day: SRGDay, from data: Data) -> AnyPublisher { let applicationConfiguration = ApplicationConfiguration.shared let vendor = applicationConfiguration.vendor - + if !applicationConfiguration.tvGuideOtherBouquets.isEmpty { return Publishers.CombineLatest( channels(for: vendor, mainProvider: true, day: day), @@ -180,8 +177,7 @@ private extension ProgramGuideViewModel { .map { Data(mainPartyChannels: $0, otherPartyChannels: $1, selectedChannel: matchingChannel(data.selectedChannel, in: $0 + $1)) } .replaceError(with: data) .eraseToAnyPublisher() - } - else { + } else { return channels(for: vendor, mainProvider: true, day: day) .map { Data(mainPartyChannels: $0, otherPartyChannels: [], selectedChannel: matchingChannel(data.selectedChannel, in: $0)) } .replaceError(with: data) diff --git a/Application/Sources/ProgramGuide/ProgramPreview.swift b/Application/Sources/ProgramGuide/ProgramPreview.swift index 80ea8371d..d463579b4 100644 --- a/Application/Sources/ProgramGuide/ProgramPreview.swift +++ b/Application/Sources/ProgramGuide/ProgramPreview.swift @@ -14,13 +14,13 @@ import SwiftUI /// Behavior: h-exp, v-exp struct ProgramPreview: View { @Binding var data: ProgramAndChannel? - + @StateObject private var model = ProgramPreviewModel() - + init(data: ProgramAndChannel?) { _data = .constant(data) } - + var body: some View { ZStack { ImageView(source: model.imageUrl) @@ -29,7 +29,7 @@ struct ProgramPreview: View { .layoutPriority(1) .overlay(ImageOverlay()) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) - + // Use stack with competing views to have a 50/50 horizontal split HStack { DescriptionView(model: model) @@ -44,11 +44,11 @@ struct ProgramPreview: View { model.data = newValue } } - + /// Behavior: h-exp, v-exp struct DescriptionView: View { @ObservedObject var model: ProgramPreviewModel - + var body: some View { VStack(alignment: .leading, spacing: 4) { if let properties = model.availabilityBadgeProperties { @@ -74,7 +74,7 @@ struct ProgramPreview: View { .padding(.horizontal, 56) } } - + /// Behavior: h-exp, v-exp private struct ImageOverlay: View { var body: some View { diff --git a/Application/Sources/ProgramGuide/ProgramPreviewModel.swift b/Application/Sources/ProgramGuide/ProgramPreviewModel.swift index 3f0432490..10f5a0e13 100644 --- a/Application/Sources/ProgramGuide/ProgramPreviewModel.swift +++ b/Application/Sources/ProgramGuide/ProgramPreviewModel.swift @@ -12,46 +12,44 @@ import SRGDataProviderModel final class ProgramPreviewModel: ObservableObject { @Published var data: ProgramAndChannel? @Published private(set) var date = Date() - + private var program: SRGProgram? { - return data?.program + data?.program } - + private var isLive: Bool { guard let program else { return false } - return (program.startDate...program.endDate).contains(date) + return (program.startDate ... program.endDate).contains(date) } - + var availabilityBadgeProperties: MediaDescription.BadgeProperties? { if isLive { - return MediaDescription.liveBadgeProperties() - } - else { - return nil + MediaDescription.liveBadgeProperties() + } else { + nil } } - + private var primaryTitle: String { - return program?.title ?? .placeholder(length: 16) + program?.title ?? .placeholder(length: 16) } - + private var secondaryTitle: String? { - return program?.subtitle ?? program?.lead + program?.subtitle ?? program?.lead } - + var subtitle: String? { - return secondaryTitle != nil ? primaryTitle : nil + secondaryTitle != nil ? primaryTitle : nil } - + var title: String { if let secondaryTitle { - return secondaryTitle - } - else { - return primaryTitle + secondaryTitle + } else { + primaryTitle } } - + var timeInformation: String { guard let program else { return .placeholder(length: 8) } let nowDate = Date() @@ -59,19 +57,18 @@ final class ProgramPreviewModel: ObservableObject { let remainingTimeInterval = program.endDate.timeIntervalSince(nowDate) let remainingTime = PlayRemainingTimeFormattedDuration(remainingTimeInterval) return String(format: NSLocalizedString("%@ remaining", comment: "Text displayed on live cells telling how much time remains for a program currently on air"), remainingTime) - } - else { + } else { let startTime = DateFormatter.play_time.string(from: program.startDate) let endTime = DateFormatter.play_time.string(from: program.endDate) // Unbreakable spaces before / after the separator return "\(startTime) - \(endTime)" } } - + var imageUrl: URL? { - return data?.programGuideImageUrl(size: .large) + data?.programGuideImageUrl(size: .large) } - + init() { Timer.publish(every: 10, on: .main, in: .common) .autoconnect() diff --git a/Application/Sources/ProgramGuide/ProgramView.swift b/Application/Sources/ProgramGuide/ProgramView.swift index bf6660812..3b49bc091 100644 --- a/Application/Sources/ProgramGuide/ProgramView.swift +++ b/Application/Sources/ProgramGuide/ProgramView.swift @@ -13,15 +13,15 @@ import SwiftUI struct ProgramView: View { @Binding var data: ProgramAndChannel @StateObject private var model = ProgramViewModel() - + static func viewController(for program: SRGProgram, channel: PlayChannel) -> UIViewController { - return ProgramViewController(program: program, channel: channel) + ProgramViewController(program: program, channel: channel) } - + init(program: SRGProgram, channel: PlayChannel) { _data = .constant(.init(program: program, channel: channel)) } - + var body: some View { VStack(spacing: 18) { Handle { @@ -59,11 +59,11 @@ struct ProgramView: View { model.data = newValue } } - + // Behavior: h-exp, v-exp private struct InteractiveVisualView: View { @ObservedObject var model: ProgramViewModel - + var body: some View { Group { if let action = model.playAction { @@ -75,26 +75,25 @@ struct ProgramView: View { .foregroundColor(.white) } } - } - else { + } else { VisualView(model: model) } } } } - + // Behavior: h-exp, v-exp private struct VisualView: View { @ObservedObject var model: ProgramViewModel - + static let padding: CGFloat = 6 - + var body: some View { ZStack { ImageView(source: model.imageUrl) .background(Color.thumbnailBackground) BlockingOverlay(media: model.currentMedia, messageDisplayed: true) - + if let progress = model.progress { ProgressBar(value: progress) .frame(height: LayoutProgressBarHeight) @@ -103,18 +102,18 @@ struct ProgramView: View { } } } - + // Behavior: h-exp, v-hug private struct ActionsView: View { @ObservedObject var model: ProgramViewModel @Environment(\.uiHorizontalSizeClass) private var horizontalSizeClass - + static let buttonHeight: CGFloat = 40 - + private var direction: StackDirection { - return horizontalSizeClass == .compact ? .vertical : .horizontal + horizontalSizeClass == .compact ? .vertical : .horizontal } - + var body: some View { Stack(direction: direction, spacing: 8) { if let properties = model.watchLaterButtonProperties { @@ -132,15 +131,15 @@ struct ProgramView: View { } } } - + // Behavior: h-exp, v-hug private struct YouthProtectionView: View { let color: SRGYouthProtectionColor - + init(color: SRGYouthProtectionColor) { self.color = color } - + var body: some View { HStack(spacing: 8) { YouthProtectionBadge(color: color) @@ -155,11 +154,11 @@ struct ProgramView: View { .frame(maxWidth: .infinity, alignment: .leading) } } - + // Behavior: h-exp, v-hug private struct AdditionnalInformationView: View { @ObservedObject var model: ProgramViewModel - + var body: some View { VStack(spacing: 8) { if let durationAndProduction = model.durationAndProduction { @@ -175,15 +174,15 @@ struct ProgramView: View { } } } - + // Behavior: h-exp, v-hug private struct CrewMembersView: View { let crewMembersDatas: [ProgramViewModel.CrewMembersData] - + init(datas: [ProgramViewModel.CrewMembersData]) { crewMembersDatas = datas } - + var body: some View { VStack(alignment: .leading, spacing: 18) { ForEach(crewMembersDatas) { crewMembersData in @@ -208,11 +207,11 @@ struct ProgramView: View { } } } - + // Behavior: h-exp, v-hug private struct DescriptionView: View { @ObservedObject var model: ProgramViewModel - + var body: some View { VStack(spacing: 8) { if let lead = model.lead { @@ -239,11 +238,11 @@ struct ProgramView: View { } } } - + // Behavior: h-hug, v-hug private struct TitleView: View { @ObservedObject var model: ProgramViewModel - + var body: some View { VStack(spacing: 8) { if let properties = model.availabilityBadgeProperties { @@ -289,8 +288,9 @@ private final class ProgramViewController: UIHostingController { init(program: SRGProgram, channel: PlayChannel) { super.init(rootView: ProgramView(program: program, channel: channel)) } - - @MainActor dynamic required init?(coder aDecoder: NSCoder) { + + @available(*, unavailable) + @MainActor dynamic required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } } @@ -299,11 +299,11 @@ private final class ProgramViewController: UIHostingController { private extension ProgramView { var accessibilityLabel: String? { - return model.playAction != nil ? PlaySRGAccessibilityLocalizedString("Play", comment: "Play button label") : nil + model.playAction != nil ? PlaySRGAccessibilityLocalizedString("Play", comment: "Play button label") : nil } - + var accessibilityTraits: AccessibilityTraits { - return .isButton + .isButton } } @@ -311,7 +311,7 @@ private extension ProgramView { struct ProgramView_Previews: PreviewProvider { private static let size = CGSize(width: 320, height: 1200) - + static var previews: some View { Group { ProgramView(program: Mock.program(), channel: Mock.playChannel()) diff --git a/Application/Sources/ProgramGuide/ProgramViewModel.swift b/Application/Sources/ProgramGuide/ProgramViewModel.swift index a611cb175..0cddf9d3a 100644 --- a/Application/Sources/ProgramGuide/ProgramViewModel.swift +++ b/Application/Sources/ProgramGuide/ProgramViewModel.swift @@ -25,57 +25,57 @@ final class ProgramViewModel: ObservableObject { eventEditViewDelegateObject.channel = data?.channel.wrappedValue } } - + @Published private var mediaData: MediaData = .empty @Published private var livestreamMedia: SRGMedia? - + @Published private(set) var date = Date() - + private let eventEditViewDelegateObject = EventEditViewDelegateObject() - + init() { Timer.publish(every: 10, on: .main, in: .common) .autoconnect() .assign(to: &$date) } - + private var program: SRGProgram? { - return data?.program + data?.program } - + private var media: SRGMedia? { - return mediaData.media + mediaData.media } - + private var show: SRGShow? { - return media?.show ?? program?.show + media?.show ?? program?.show } - + private var channel: SRGChannel? { - return data?.channel.wrappedValue + data?.channel.wrappedValue } - + private var isLive: Bool { guard let program else { return false } - return (program.startDate...program.endDate).contains(date) + return (program.startDate ... program.endDate).contains(date) } - + var title: String? { - return program?.title + program?.title } - + var subtitle: String? { - return program?.subtitle + program?.subtitle } - + var lead: String? { - return program?.lead + program?.lead } - + var summary: String? { - return program?.summary + program?.summary } - + var timeAndDate: String? { guard let program else { return nil } let startTime = DateFormatter.play_time.string(from: program.startDate) @@ -83,24 +83,24 @@ final class ProgramViewModel: ObservableObject { let day = DateFormatter.play_relativeFullDate.string(from: program.startDate) return "\(startTime) - \(endTime) · \(day)" } - + var timeAndDateAccessibilityLabel: String? { guard let program else { return nil } return String(format: PlaySRGAccessibilityLocalizedString("From %1$@ to %2$@", comment: "Text providing program time information. First placeholder is the start time, second is the end time."), PlayAccessibilityTimeFromDate(program.startDate), PlayAccessibilityTimeFromDate(program.endDate)) .appending(", ") .appending(DateFormatter.play_relativeFullDate.string(from: program.startDate)) } - + private var seasonNumber: NSNumber? { guard let seasonNumber = program?.seasonNumber else { return nil } return seasonNumber.intValue > 0 ? seasonNumber : nil } - + private var episodeNumber: NSNumber? { guard let episodeNumber = program?.episodeNumber else { return nil } return episodeNumber.intValue > 0 ? episodeNumber : nil } - + var serie: String? { let seaon = seasonNumber != nil ? "\(NSLocalizedString("Season", comment: "Season of a serie")) \(seasonNumber!)" : nil let episode = episodeNumber != nil ? "\(NSLocalizedString("Episode", comment: "Episode of a serie")) \(episodeNumber!)" : nil @@ -109,22 +109,22 @@ final class ProgramViewModel: ObservableObject { .joined(separator: " · ") return !serie.isEmpty ? serie : nil } - + var youthProtectionColor: SRGYouthProtectionColor? { let youthProtectionColor = program?.youthProtectionColor return youthProtectionColor != SRGYouthProtectionColor.none ? youthProtectionColor : nil } - + var imageUrl: URL? { - return data?.programGuideImageUrl(size: .medium) + data?.programGuideImageUrl(size: .medium) } - + private var duration: Double? { guard let program else { return nil } let duration = program.endDate.timeIntervalSince(program.startDate) return duration > 0 ? duration : nil } - + private var production: String? { let year = program?.productionYear?.stringValue let production = [program?.productionCountry, year] @@ -132,7 +132,7 @@ final class ProgramViewModel: ObservableObject { .joined(separator: " ") return !production.isEmpty ? production : nil } - + var durationAndProduction: String? { guard let program else { return nil } let durationString = duration != nil ? PlayFormattedMinutes(duration!) : nil @@ -141,129 +141,123 @@ final class ProgramViewModel: ObservableObject { .joined(separator: " · ") return !durationAndProduction.isEmpty ? durationAndProduction : nil } - + var badgesListData: BadgeList.Data? { guard let program else { return nil } return BadgeList.data(for: program) } - + var crewMembersDatas: [CrewMembersData]? { guard let crewMembers = program?.crewMembers, !crewMembers.isEmpty else { return nil } return OrderedDictionary(grouping: crewMembers, by: { $0.role }).map { role, crewMembers in - return CrewMembersData(role: role, crewMembers: crewMembers) + CrewMembersData(role: role, crewMembers: crewMembers) } } - + var imageCopyright: String? { guard let imageCopyright = program?.imageCopyright else { return nil } return String(format: NSLocalizedString("Image credit: %@", comment: "Image copyright introductory label"), imageCopyright) } - + var progress: Double? { if isLive, let program { let progress = date.timeIntervalSince(program.startDate) / program.endDate.timeIntervalSince(program.startDate) - return (0...1).contains(progress) ? progress : nil - } - else { + return (0 ... 1).contains(progress) ? progress : nil + } else { return mediaData.progress } } - + var currentMedia: SRGMedia? { - return isLive ? livestreamMedia : media + isLive ? livestreamMedia : media } - + var availabilityBadgeProperties: MediaDescription.BadgeProperties? { if isLive { - return MediaDescription.liveBadgeProperties() - } - else if let media = currentMedia { - return MediaDescription.availabilityBadgeProperties(for: media) - } - else { - return nil + MediaDescription.liveBadgeProperties() + } else if let media = currentMedia { + MediaDescription.availabilityBadgeProperties(for: media) + } else { + nil } } - + var playAction: (() -> Void)? { if let media = currentMedia, media.blockingReason(at: Date()) == .none, let tabBarController = UIApplication.shared.mainTabBarController { - return { [self] in + { [self] in if let data { if media.contentType == .livestream { AnalyticsClickEvent.tvGuidePlayLivestream(program: data.program, channel: data.channel.wrappedValue).send() - } - else { + } else { AnalyticsClickEvent.tvGuidePlayMedia(media: media, programIsLive: isLive, channel: data.channel.wrappedValue).send() } } - + tabBarController.dismissAndPresentMediaPlayer(with: media, position: nil) } - } - else { - return nil + } else { + nil } } - + var hasActions: Bool { - return watchFromStartButtonProperties != nil || watchLaterButtonProperties != nil || calendarButtonProperties != nil + watchFromStartButtonProperties != nil || watchLaterButtonProperties != nil || calendarButtonProperties != nil } - + var watchFromStartButtonProperties: ButtonProperties? { guard isLive, let media, media.blockingReason(at: Date()) == .none else { return nil } - - let data = self.data + + let data = data let analyticsClickEvent = data != nil ? AnalyticsClickEvent.tvGuidePlayMedia(media: media, programIsLive: true, channel: data!.channel.wrappedValue) : nil return ButtonProperties( icon: .startOver, label: NSLocalizedString("Watch from start", comment: "Button to watch some program from the start"), action: { guard let tabBarController = UIApplication.shared.mainTabBarController else { return } - if HistoryCanResumePlaybackForMedia(media) && HistoryPlaybackProgressForMedia(media) != 0 { + if HistoryCanResumePlaybackForMedia(media), HistoryPlaybackProgressForMedia(media) != 0 { let alertController = UIAlertController(title: NSLocalizedString("Watch from start?", comment: "Resume playback alert title"), message: NSLocalizedString("You already played this content.", comment: "Resume playback alert explanation"), preferredStyle: .alert) alertController.addAction(UIAlertAction(title: NSLocalizedString("Resume", comment: "Alert choice to resume playback"), style: .default, handler: { _ in analyticsClickEvent?.send() - + tabBarController.dismissAndPresentMediaPlayer(with: media, position: nil) })) alertController.addAction(UIAlertAction(title: NSLocalizedString("Watch from start", comment: "Alert choice to watch content from start"), style: .default, handler: { _ in analyticsClickEvent?.send() - + tabBarController.dismissAndPresentMediaPlayer(with: media, position: .default) })) alertController.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "Title of a cancel button"), style: .cancel, handler: nil)) tabBarController.play_top.present(alertController, animated: true, completion: nil) - } - else { + } else { analyticsClickEvent?.send() - + tabBarController.dismissAndPresentMediaPlayer(with: media, position: nil) } } ) } - + private var watchLaterAllowedAction: WatchLaterAction { - return mediaData.watchLaterAllowedAction + mediaData.watchLaterAllowedAction } - + var watchLaterButtonProperties: ButtonProperties? { guard !isLive, let media else { return nil } - + func toggleWatchLater() { WatchLaterToggleMedia(media) { added, error in guard error == nil else { return } - + let action = added ? .add : .remove as AnalyticsListAction AnalyticsEvent.watchLater(action: action, source: .button, urn: media.urn).send() - + self.mediaData = MediaData(media: media, watchLaterAllowedAction: added ? .remove : .add, progress: self.mediaData.progress) } } - + switch watchLaterAllowedAction { case .add: switch media.mediaType { @@ -290,9 +284,9 @@ final class ProgramViewModel: ObservableObject { return nil } } - + var calendarButtonProperties: ButtonProperties? { - return ButtonProperties( + ButtonProperties( icon: .calendar, label: NSLocalizedString("Add to Calendar", comment: "Button to add an event to Calendar application"), action: { @@ -306,7 +300,7 @@ final class ProgramViewModel: ObservableObject { Banner.showError(error as NSError?) return } - + guard let self else { return } if granted { let event = EKEvent(eventStore: eventStore) @@ -315,14 +309,13 @@ final class ProgramViewModel: ObservableObject { event.endDate = program.endDate event.url = self.calendarUrl event.notes = self.calendarNotes - + let eventController = EKEventEditViewController() eventController.event = event eventController.eventStore = eventStore eventController.editViewDelegate = self.eventEditViewDelegateObject tabBarController.play_top.present(eventController, animated: true, completion: nil) - } - else { + } else { let applicationName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as! String let alertController = UIAlertController(title: String(format: NSLocalizedString("“%@” would like to access to your calendar", comment: "Add to Calendar alert title"), applicationName), message: NSLocalizedString("The application uses the calendar to add TV programs.", comment: "Add to Calendar alert explanation"), @@ -338,7 +331,7 @@ final class ProgramViewModel: ObservableObject { } ) } - + var showButtonProperties: ShowButtonProperties? { guard let show else { return nil } return ShowButtonProperties( @@ -346,7 +339,8 @@ final class ProgramViewModel: ObservableObject { isFavorite: FavoritesContainsShow(show), action: { guard let tabBarController = UIApplication.shared.mainTabBarController, - let window = UIApplication.shared.mainWindow else { + let window = UIApplication.shared.mainWindow + else { return } let pageViewController = PageViewController(id: .show(show)) @@ -355,35 +349,31 @@ final class ProgramViewModel: ObservableObject { } ) } - + private var calendarUrl: URL? { - if let media = self.media { - return ApplicationConfiguration.shared.sharingURL(for: media, at: .zero) - } - else if let media = self.livestreamMedia, program?.timeAvailability(at: Date()) == .notYetAvailable { - return ApplicationConfiguration.shared.sharingURL(for: media, at: .zero) - } - else if let show { - return ApplicationConfiguration.shared.sharingURL(for: show) - } - else { - return ApplicationConfiguration.shared.playURL(for: self.channel?.vendor ?? ApplicationConfiguration.shared.vendor) + if let media { + ApplicationConfiguration.shared.sharingURL(for: media, at: .zero) + } else if let media = livestreamMedia, program?.timeAvailability(at: Date()) == .notYetAvailable { + ApplicationConfiguration.shared.sharingURL(for: media, at: .zero) + } else if let show { + ApplicationConfiguration.shared.sharingURL(for: show) + } else { + ApplicationConfiguration.shared.playURL(for: channel?.vendor ?? ApplicationConfiguration.shared.vendor) } } - + private var calendarNotes: String? { let notes = [calendarShowNote, subtitle, summary] .compactMap { $0 } .joined(separator: "\n\n") return !notes.isEmpty ? notes : nil } - + private var calendarShowNote: String? { guard let show else { return nil } if let url = ApplicationConfiguration.shared.sharingURL(for: show) { return "\(show.title)\n\(url)" - } - else { + } else { return show.title } } @@ -394,43 +384,41 @@ final class ProgramViewModel: ObservableObject { extension ProgramViewModel { private static func mediaDataPublisher(for program: SRGProgram?) -> AnyPublisher { if let mediaUrn = program?.mediaURN { - return Publishers.PublishAndRepeat(onOutputFrom: ApplicationSignal.wokenUp()) { - return SRGDataProvider.current!.media(withUrn: mediaUrn) + Publishers.PublishAndRepeat(onOutputFrom: ApplicationSignal.wokenUp()) { + SRGDataProvider.current!.media(withUrn: mediaUrn) .catch { _ in - return Empty() + Empty() } } .map { media in - return Publishers.CombineLatest(UserDataPublishers.laterAllowedActionPublisher(for: media), UserDataPublishers.playbackProgressPublisher(for: media)) + Publishers.CombineLatest(UserDataPublishers.laterAllowedActionPublisher(for: media), UserDataPublishers.playbackProgressPublisher(for: media)) .map { action, progress in - return MediaData(media: media, watchLaterAllowedAction: action, progress: progress) + MediaData(media: media, watchLaterAllowedAction: action, progress: progress) } .eraseToAnyPublisher() } .switchToLatest() .prepend(.empty) .eraseToAnyPublisher() - } - else { - return Just(.empty) + } else { + Just(.empty) .eraseToAnyPublisher() } } - + private static func livestreamMediaPublisher(for channel: SRGChannel?) -> AnyPublisher { if let channel { - return Publishers.PublishAndRepeat(onOutputFrom: ApplicationSignal.wokenUp()) { - return SRGDataProvider.current!.tvLivestreams(for: channel.vendor) + Publishers.PublishAndRepeat(onOutputFrom: ApplicationSignal.wokenUp()) { + SRGDataProvider.current!.tvLivestreams(for: channel.vendor) .catch { _ in - return Empty() + Empty() } } .map { $0.first(where: { $0.channel == channel }) } .prepend(nil) .eraseToAnyPublisher() - } - else { - return Just(nil) + } else { + Just(nil) .eraseToAnyPublisher() } } @@ -443,44 +431,43 @@ extension ProgramViewModel { struct CrewMembersData: Identifiable { let role: String? let names: [String] - + var id: String? { - return role + role } - + init(role: String?, crewMembers: [SRGCrewMember]) { self.role = role - self.names = crewMembers.map { crewMember in + names = crewMembers.map { crewMember in if let characterName = crewMember.characterName { - return "\(crewMember.name) (\(characterName))" - } - else { - return crewMember.name + "\(crewMember.name) (\(characterName))" + } else { + crewMember.name } } } - + var accessibilityLabel: String { - return "\(role ?? "") \(names.joined(separator: ", "))".trimmingCharacters(in: .whitespaces) + "\(role ?? "") \(names.joined(separator: ", "))".trimmingCharacters(in: .whitespaces) } } - + /// Data related to the media stored by the model private struct MediaData { let media: SRGMedia? let watchLaterAllowedAction: WatchLaterAction let progress: Double? - + static var empty = Self(media: nil, watchLaterAllowedAction: .none, progress: nil) } - + /// Common button properties struct ButtonProperties { let icon: ImageResource let label: String let action: () -> Void } - + /// Show button properties struct ShowButtonProperties { let show: SRGShow @@ -489,16 +476,15 @@ extension ProgramViewModel { } } -fileprivate extension TabBarController { +private extension TabBarController { func dismissAndPresentMediaPlayer(with media: SRGMedia, position: SRGPosition?) { - var presentMediaPlayer: Void { self.play_presentMediaPlayer(with: media, position: position, airPlaySuggestions: true, fromPushNotification: false, animated: true, completion: nil) } - - if let presentedViewController = self.presentedViewController { + var presentMediaPlayer: Void { play_presentMediaPlayer(with: media, position: position, airPlaySuggestions: true, fromPushNotification: false, animated: true, completion: nil) } + + if let presentedViewController { presentedViewController.dismiss(animated: true) { presentMediaPlayer } - } - else { + } else { presentMediaPlayer } } @@ -508,12 +494,12 @@ fileprivate extension TabBarController { private final class EventEditViewDelegateObject: NSObject, EKEventEditViewDelegate { var channel: SRGChannel? - + func eventEditViewController(_ controller: EKEventEditViewController, didCompleteWith action: EKEventEditViewAction) { controller.dismiss(animated: true) { if action == .saved, let title = controller.event?.title { Banner.calendarEventAddedWithTitle(title) - + if let channel = self.channel { AnalyticsEvent.calendarEventAdd(channel: channel).send() } @@ -528,9 +514,8 @@ private extension EKEventStore { func requestAccessToEvents(completion: @escaping EKEventStoreRequestAccessCompletionHandler) { if #available(iOS 17.0, *) { self.requestWriteOnlyAccessToEvents(completion: completion) - } - else { - self.requestAccess( to: EKEntityType.event, completion: completion) + } else { + requestAccess(to: EKEntityType.event, completion: completion) } } } diff --git a/Application/Sources/ProgramGuide/TimelineView.swift b/Application/Sources/ProgramGuide/TimelineView.swift index c5d5ae5d3..4c0eed856 100644 --- a/Application/Sources/ProgramGuide/TimelineView.swift +++ b/Application/Sources/ProgramGuide/TimelineView.swift @@ -13,47 +13,46 @@ import SwiftUI /// Behavior: h-exp, v-exp struct TimelineView: View { let dateInterval: DateInterval? - + private static func label(for date: Date) -> String { - return DateFormatter.play_time.string(from: date) + DateFormatter.play_time.string(from: date) } - + private func xPosition(for date: Date, width: CGFloat) -> CGFloat { guard let dateInterval else { return 0 } return ProgramGuideGridLayout.timelinePadding + ProgramGuideGridLayout.channelHeaderWidth + ProgramGuideGridLayout.horizontalSpacing + (width - ProgramGuideGridLayout.timelinePadding) * date.timeIntervalSince(dateInterval.start) / dateInterval.duration } - + private func enumerateDates(matching dateComponents: DateComponents) -> [Date] { guard let dateInterval else { return [] } - + var dates = [Date]() Calendar.srgDefault.enumerateDates(startingAfter: dateInterval.start, matching: dateComponents, matchingPolicy: .nextTime) { date, _, stop in guard let date else { return } if dateInterval.contains(date) { dates.append(date) - } - else { + } else { stop = true } } return dates } - + private var dates: [Date] { var dates = [Date]() - + var hourDateComponents = DateComponents() hourDateComponents.minute = 0 dates.append(contentsOf: enumerateDates(matching: hourDateComponents)) - + var halfHourDateComponents = DateComponents() halfHourDateComponents.minute = 30 dates.append(contentsOf: enumerateDates(matching: halfHourDateComponents)) - + return dates.sorted() } - + var body: some View { GeometryReader { geometry in ForEach(dates, id: \.self) { date in @@ -69,12 +68,12 @@ struct TimelineView: View { .padding(.bottom, 8) } } -#if os(iOS) + #if os(iOS) .background(Color.srgGray16) -#else + #else .background(Color(white: 0, opacity: 0.2)) .background(Blur(style: .dark)) -#endif + #endif .accessibilityHidden(true) } } @@ -83,7 +82,7 @@ struct TimelineView: View { struct TimelineView_Previews: PreviewProvider { private static let dateInterval = DateInterval(start: Date(), duration: 60 * 60 * 24) - + static var previews: some View { TimelineView(dateInterval: dateInterval) .previewLayout(.fixed(width: 4000, height: 100)) diff --git a/Application/Sources/ProgramGuide/VerticalNowHeadView.swift b/Application/Sources/ProgramGuide/VerticalNowHeadView.swift index 8c64071bd..d91ec8bc2 100644 --- a/Application/Sources/ProgramGuide/VerticalNowHeadView.swift +++ b/Application/Sources/ProgramGuide/VerticalNowHeadView.swift @@ -12,13 +12,13 @@ import SwiftUI struct VerticalNowArrowView: View { static let width: CGFloat = 13 static let headerHeight: CGFloat = 8 - + var body: some View { Triangle() .fill(.white) .frame(width: Self.width, height: Self.headerHeight) } - + private struct Triangle: Shape { func path(in rect: CGRect) -> Path { var path = Path() diff --git a/Application/Sources/RadioChannels/RadioChannelsViewController.swift b/Application/Sources/RadioChannels/RadioChannelsViewController.swift index 566dbbf3e..77e854995 100644 --- a/Application/Sources/RadioChannels/RadioChannelsViewController.swift +++ b/Application/Sources/RadioChannels/RadioChannelsViewController.swift @@ -8,60 +8,59 @@ import UIKit @objc class RadioChannelsViewController: PageContainerViewController { private var radioChannelName: String? - + @objc init(radioChannels: [RadioChannel]) { assert(!radioChannels.isEmpty, "At least 1 radio channel expected") - + var viewControllers = [UIViewController]() for (index, radioChannel) in radioChannels.enumerated() { let pageViewController = PageViewController.audiosViewController(forRadioChannel: radioChannel) pageViewController.tabBarItem = UITabBarItem(title: radioChannel.name, image: RadioChannelLogoImage(radioChannel), tag: index) viewControllers.append(pageViewController) } - + let lastOpenedRadioChannel = ApplicationSettingLastOpenedRadioChannel() - let initialPage: Int - if let lastOpenedRadioChannel { - initialPage = radioChannels.firstIndex(of: lastOpenedRadioChannel) ?? NSNotFound + let initialPage: Int = if let lastOpenedRadioChannel { + radioChannels.firstIndex(of: lastOpenedRadioChannel) ?? NSNotFound } else { - initialPage = NSNotFound + NSNotFound } - + super.init(viewControllers: viewControllers, initialPage: initialPage) updateTitle() } - - required init?(coder: NSCoder) { + + @available(*, unavailable) + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func viewDidLoad() { super.viewDidLoad() - + updateTitle() - + if let navigationBar = navigationController?.navigationBar { navigationItem.rightBarButtonItem = GoogleCastBarButtonItem(for: navigationBar) } } - - override func didDisplayViewController(_ viewController: UIViewController, animated: Bool) { + + override func didDisplayViewController(_ viewController: UIViewController?, animated: Bool) { super.didDisplayViewController(viewController, animated: animated) - - guard let pageViewController = viewController as? PageViewController, - let radioChannel = pageViewController.radioChannel else { return } - + + guard let radioChannel = (viewController as? PageViewController)?.radioChannel else { return } + radioChannelName = radioChannel.name - + ApplicationSettingSetLastOpenedRadioChannel(radioChannel) - + if let navigationController = navigationController as? NavigationController { navigationController.update(with: radioChannel, animated: animated) } - + updateTitle() } - + private func updateTitle() { navigationItem.title = radioChannelName ?? NSLocalizedString("Audios", comment: "Title displayed at the top of the audio view") } @@ -72,14 +71,14 @@ import UIKit extension RadioChannelsViewController: PlayApplicationNavigation { func open(_ applicationSectionInfo: ApplicationSectionInfo) -> Bool { guard let radioChannel = applicationSectionInfo.radioChannel else { return false } - + if let radioChannelViewController = viewControllers.first(where: { ($0 as? PageViewController)?.radioChannel == radioChannel }) as? UIViewController & PlayApplicationNavigation, let pageIndex = viewControllers.firstIndex(of: radioChannelViewController) { - _ = self.switchToIndex(pageIndex, animated: false) - + _ = switchToIndex(pageIndex, animated: false) + return radioChannelViewController.open(applicationSectionInfo) } - + return false } } diff --git a/Application/Sources/Search/SearchSettingsBucketCell.swift b/Application/Sources/Search/SearchSettingsBucketCell.swift index 30371a35e..58d8f4fb4 100644 --- a/Application/Sources/Search/SearchSettingsBucketCell.swift +++ b/Application/Sources/Search/SearchSettingsBucketCell.swift @@ -12,9 +12,9 @@ import SwiftUI struct SearchSettingsBucketCell: View { let bucket: SRGItemBucket - + @Binding var selectedUrns: Set - + var body: some View { Button(action: toggleSelection) { HStack { @@ -30,20 +30,19 @@ struct SearchSettingsBucketCell: View { .foregroundColor(.primary) .accessibilityElement(label: accessibilityLabel, hint: nil, traits: accessibilityTraits) } - + private var title: String { - return "\(bucket.title) (\(NumberFormatter.localizedString(from: bucket.count as NSNumber, number: .decimal)))" + "\(bucket.title) (\(NumberFormatter.localizedString(from: bucket.count as NSNumber, number: .decimal)))" } - + private var isSelected: Bool { - return selectedUrns.contains(bucket.urn) + selectedUrns.contains(bucket.urn) } - + private func toggleSelection() { if isSelected { selectedUrns.remove(bucket.urn) - } - else { + } else { selectedUrns.update(with: bucket.urn) } } @@ -56,9 +55,9 @@ private extension SearchSettingsBucketCell { let contents = String(format: PlaySRGAccessibilityLocalizedString("%@ results", comment: "Number of results aggregated in search"), PlayAccessibilityNumberFormatter(bucket.count as NSNumber)) return "\(bucket.title) (\(contents))" } - + var accessibilityTraits: AccessibilityTraits { - return isSelected ? [.isButton, .isSelected] : .isButton + isSelected ? [.isButton, .isSelected] : .isButton } } @@ -66,7 +65,7 @@ private extension SearchSettingsBucketCell { struct SearchSettingsBucketCell_Previews: PreviewProvider { private static let size = CGSize(width: 320, height: 36) - + static var previews: some View { Group { SearchSettingsBucketCell(bucket: Mock.bucket(.standard), selectedUrns: .constant([])) diff --git a/Application/Sources/Search/SearchSettingsBucketsView.swift b/Application/Sources/Search/SearchSettingsBucketsView.swift index 0203f9ae7..1f1809ccf 100644 --- a/Application/Sources/Search/SearchSettingsBucketsView.swift +++ b/Application/Sources/Search/SearchSettingsBucketsView.swift @@ -12,16 +12,16 @@ import SwiftUI struct SearchSettingsBucketsView: View { let title: String let buckets: [SRGItemBucket] - + @Binding var selectedUrns: Set @State private var searchText = "" @FirstResponder private var firstResponder - + private var filteredBuckets: [SRGItemBucket] { guard !searchText.isEmpty else { return buckets } return buckets.filter { $0.title.localizedCaseInsensitiveContains(searchText) } } - + var body: some View { List { SearchBarView(text: $searchText, placeholder: NSLocalizedString("Search", comment: "Search shortcut label"), autocapitalizationType: .none) diff --git a/Application/Sources/Search/SearchSettingsNavigationView.swift b/Application/Sources/Search/SearchSettingsNavigationView.swift index cce69764e..511b3e5b6 100644 --- a/Application/Sources/Search/SearchSettingsNavigationView.swift +++ b/Application/Sources/Search/SearchSettingsNavigationView.swift @@ -10,9 +10,9 @@ import SwiftUI struct SearchSettingsNavigationView: View { @ObservedObject var model: SearchViewModel - + @FirstResponder private var firstResponder - + var body: some View { PlayNavigationView { SearchSettingsView(query: $model.query, settings: $model.settings) @@ -27,7 +27,7 @@ struct SearchSettingsNavigationView: View { } } } - + ToolbarItem { Button { firstResponder.sendAction(#selector(SearchSettingsNavigationViewController.close(_:))) @@ -48,19 +48,20 @@ final class SearchSettingsNavigationViewController: UIHostingController MediaSearchSettings { var enrichedSettings = settings ?? MediaSearchSettings() enrichedSettings.aggregationsEnabled = true return enrichedSettings } - + private static func description(forSelectedUrns selectedUrns: Set?, in buckets: [SRGItemBucket]) -> String? { guard let selectedUrns else { return nil } let selectedBuckets = buckets @@ -29,7 +29,7 @@ final class SearchSettingsViewModel: ObservableObject { guard !selectedBuckets.isEmpty else { return nil } return selectedBuckets.map(\.title).joined(separator: ", ") } - + init() { // Drop initial values; relevant values are first assigned when the view appears Publishers.CombineLatest($query.dropFirst(), $settings.dropFirst()) @@ -40,7 +40,7 @@ final class SearchSettingsViewModel: ObservableObject { return SRGDataProvider.current!.medias(for: vendor, matchingQuery: query, with: enrichedSettings.requestSettings) .map { State.loaded(aggregations: $0.aggregations) } .catch { error in - return Just(State.failed(error: error)) + Just(State.failed(error: error)) } .prepend(State.loading(aggregations: self?.aggregations)) .eraseToAnyPublisher() @@ -50,57 +50,57 @@ final class SearchSettingsViewModel: ObservableObject { .receive(on: DispatchQueue.main) .assign(to: &$state) } - + var isLoadingFilters: Bool { switch state { case .loading: - return true + true default: - return false + false } } - + private var aggregations: SRGMediaAggregations? { switch state { case let .loading(aggregations: aggregations): - return aggregations + aggregations case let .loaded(aggregations: aggregations): - return aggregations + aggregations case .failed: - return nil + nil } } - + var hasTopicFilter: Bool { - return !topicBuckets.isEmpty + !topicBuckets.isEmpty } - + var topicBuckets: [SRGItemBucket] { - return aggregations?.topicBuckets ?? [] + aggregations?.topicBuckets ?? [] } - + var selectedTopics: String? { - return Self.description(forSelectedUrns: settings?.topicUrns, in: topicBuckets) + Self.description(forSelectedUrns: settings?.topicUrns, in: topicBuckets) } - + var hasShowFilter: Bool { - return !showBuckets.isEmpty + !showBuckets.isEmpty } - + var showBuckets: [SRGItemBucket] { - return aggregations?.showBuckets ?? [] + aggregations?.showBuckets ?? [] } - + var selectedShows: String? { - return Self.description(forSelectedUrns: settings?.showUrns, in: showBuckets) + Self.description(forSelectedUrns: settings?.showUrns, in: showBuckets) } - + var hasSubtitledFilter: Bool { - return !ApplicationConfiguration.shared.isSearchSettingSubtitledHidden + !ApplicationConfiguration.shared.isSearchSettingSubtitledHidden } - + private func reloadSignal() -> AnyPublisher { - return ApplicationSignal.wokenUp() + ApplicationSignal.wokenUp() .throttle(for: 0.5, scheduler: DispatchQueue.main, latest: false) .eraseToAnyPublisher() } diff --git a/Application/Sources/Search/SearchViewController.swift b/Application/Sources/Search/SearchViewController.swift index 83881a820..8c84860e7 100644 --- a/Application/Sources/Search/SearchViewController.swift +++ b/Application/Sources/Search/SearchViewController.swift @@ -15,28 +15,28 @@ import UIKit final class SearchViewController: UIViewController { private var model = SearchViewModel() - + private var cancellables = Set() - + private var dataSource: UICollectionViewDiffableDataSource! - + private weak var collectionView: CollectionView! private weak var emptyView: HostView! - -#if os(iOS) - private weak var filtersBarButtonItem: UIBarButtonItem? - private weak var refreshControl: UIRefreshControl! - private var defaultLeftView: UIView? // strong - - private var refreshTriggered = false - private var searchUpdateInhibited = false -#endif + + #if os(iOS) + private weak var filtersBarButtonItem: UIBarButtonItem? + private weak var refreshControl: UIRefreshControl! + private var defaultLeftView: UIView? // strong + + private var refreshTriggered = false + private var searchUpdateInhibited = false + #endif private weak var searchController: UISearchController? - + private static let itemSpacing: CGFloat = constant(iOS: 8, tvOS: 40) private static let layoutHorizontalMargin: CGFloat = constant(iOS: 16, tvOS: 0) private static let layoutVerticalMargin: CGFloat = constant(iOS: 8, tvOS: 0) - + private static func snapshot(from state: SearchViewModel.State) -> NSDiffableDataSourceSnapshot { var snapshot = NSDiffableDataSourceSnapshot() if case let .loaded(rows: rows, suggestions: _) = state { @@ -47,26 +47,27 @@ final class SearchViewController: UIViewController { } return snapshot } - + init() { super.init(nibName: nil, bundle: nil) title = TitleForApplicationSection(.search) } - - required init?(coder: NSCoder) { + + @available(*, unavailable) + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func loadView() { let view = UIView(frame: UIScreen.main.bounds) view.backgroundColor = .srgGray16 - + let collectionView = CollectionView(frame: .zero, collectionViewLayout: layout()) collectionView.delegate = self collectionView.backgroundColor = .clear view.addSubview(collectionView) self.collectionView = collectionView - + collectionView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ collectionView.topAnchor.constraint(equalTo: view.topAnchor), @@ -74,205 +75,203 @@ final class SearchViewController: UIViewController { collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) - -#if os(iOS) - if #available(iOS 17.0, *) { - collectionView.registerForTraitChanges([UITraitHorizontalSizeClass.self]) { (collectionView: UICollectionView, _) in - collectionView.collectionViewLayout.invalidateLayout() + + #if os(iOS) + if #available(iOS 17.0, *) { + collectionView.registerForTraitChanges([UITraitHorizontalSizeClass.self]) { (collectionView: UICollectionView, _) in + collectionView.collectionViewLayout.invalidateLayout() + } } - } -#endif - + #endif + let emptyView = HostView(frame: .zero) collectionView.backgroundView = emptyView self.emptyView = emptyView - -#if os(iOS) - let refreshControl = RefreshControl() - refreshControl.addTarget(self, action: #selector(pullToRefresh), for: .valueChanged) - collectionView.insertSubview(refreshControl, at: 0) - self.refreshControl = refreshControl -#endif + + #if os(iOS) + let refreshControl = RefreshControl() + refreshControl.addTarget(self, action: #selector(pullToRefresh), for: .valueChanged) + collectionView.insertSubview(refreshControl, at: 0) + self.refreshControl = refreshControl + #endif self.view = view } - + override func viewDidLoad() { super.viewDidLoad() - + let cellRegistration = UICollectionView.CellRegistration, SearchViewModel.Item> { cell, _, item in cell.content = ItemCell(item: item) // Avoid pausing a loading animation when the user taps the parent cell // See https://stackoverflow.com/questions/27904177/uiimageview-animation-stops-when-user-touches-screen/29330962 cell.isUserInteractionEnabled = (item != .loading) } - + dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item in - return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item) + collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item) } - + let sectionHeaderViewRegistration = UICollectionView.SupplementaryRegistration>(elementKind: UICollectionView.elementKindSectionHeader) { [weak self] view, _, indexPath in guard let self else { return } let snapshot = dataSource.snapshot() let section = snapshot.sectionIdentifiers[indexPath.section] view.content = SectionHeaderView(section: section, settings: model.settings) } - + dataSource.supplementaryViewProvider = { collectionView, _, indexPath in - return collectionView.dequeueConfiguredReusableSupplementary(using: sectionHeaderViewRegistration, for: indexPath) + collectionView.dequeueConfiguredReusableSupplementary(using: sectionHeaderViewRegistration, for: indexPath) } - + model.$state .sink { [weak self] state in guard let self else { return } reloadData(for: state) -#if os(tvOS) - guard let searchController = searchController else { return } - if case let .loaded(rows: _, suggestions: suggestions) = state { - if let suggestions { - searchController.searchSuggestions = suggestions.map { UISearchSuggestionItem(localizedSuggestion: $0.text) } - } - else { + #if os(tvOS) + guard let searchController else { return } + if case let .loaded(rows: _, suggestions: suggestions) = state { + if let suggestions { + searchController.searchSuggestions = suggestions.map { UISearchSuggestionItem(localizedSuggestion: $0.text) } + } else { + searchController.searchSuggestions = nil + } + } else { searchController.searchSuggestions = nil } - } - else { - searchController.searchSuggestions = nil - } -#endif - } - .store(in: &cancellables) - -#if os(iOS) - navigationItem.largeTitleDisplayMode = .always - - let searchController = UISearchController(searchResultsController: nil) - searchController.showsSearchResultsController = true - searchController.obscuresBackgroundDuringPresentation = false - searchController.hidesNavigationBarDuringPresentation = false - searchController.searchResultsUpdater = self - - navigationItem.searchController = searchController - navigationItem.hidesSearchBarWhenScrolling = false - if #available(iOS 16, *) { - navigationItem.preferredSearchBarPlacement = .stacked - } - - self.searchController = searchController - - let searchBar = searchController.searchBar - object_setClass(searchBar, SearchBar.self) - - searchBar.placeholder = NSLocalizedString("Shows, topics and more", comment: "Search placeholder text") - searchBar.autocapitalizationType = .none - searchBar.tintColor = .white - searchBar.delegate = self - - definesPresentationContext = true - - model.$query - .removeDuplicates() // Prevent recursive updates - .sink { query in - searchBar.text = query + #endif } .store(in: &cancellables) - model.$settings - .sink { [weak self] settings in - self?.updateSearchSettingsButton(for: settings) + + #if os(iOS) + navigationItem.largeTitleDisplayMode = .always + + let searchController = UISearchController(searchResultsController: nil) + searchController.showsSearchResultsController = true + searchController.obscuresBackgroundDuringPresentation = false + searchController.hidesNavigationBarDuringPresentation = false + searchController.searchResultsUpdater = self + + navigationItem.searchController = searchController + navigationItem.hidesSearchBarWhenScrolling = false + if #available(iOS 16, *) { + navigationItem.preferredSearchBarPlacement = .stacked } - .store(in: &cancellables) -#endif + + self.searchController = searchController + + let searchBar = searchController.searchBar + object_setClass(searchBar, SearchBar.self) + + searchBar.placeholder = NSLocalizedString("Shows, topics and more", comment: "Search placeholder text") + searchBar.autocapitalizationType = .none + searchBar.tintColor = .white + searchBar.delegate = self + + definesPresentationContext = true + + model.$query + .removeDuplicates() // Prevent recursive updates + .sink { query in + searchBar.text = query + } + .store(in: &cancellables) + model.$settings + .sink { [weak self] settings in + self?.updateSearchSettingsButton(for: settings) + } + .store(in: &cancellables) + #endif } - + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) model.reload() deselectItems(in: collectionView, animated: animated) -#if os(tvOS) - searchController?.searchControllerObservedScrollView = collectionView -#endif + #if os(tvOS) + searchController?.searchControllerObservedScrollView = collectionView + #endif } - + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) -#if os(tvOS) - tabBarObservedScrollView = collectionView -#endif + #if os(tvOS) + tabBarObservedScrollView = collectionView + #endif } - + override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) -#if os(iOS) - // Dismiss to avoid retain cycle if the search was entered once, see https://stackoverflow.com/a/33619501/760435 - searchController?.dismiss(animated: false, completion: nil) -#endif + #if os(iOS) + // Dismiss to avoid retain cycle if the search was entered once, see https://stackoverflow.com/a/33619501/760435 + searchController?.dismiss(animated: false, completion: nil) + #endif } - + override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) -#if os(iOS) - searchController?.searchBar.resignFirstResponder() -#endif + #if os(iOS) + searchController?.searchBar.resignFirstResponder() + #endif } - -#if os(iOS) - private func updateSearchSettingsButton(for settings: MediaSearchSettings) { - guard !ApplicationConfiguration.shared.areSearchSettingsHidden else { - navigationItem.rightBarButtonItem = nil - return - } - - if filtersBarButtonItem == nil { - let filtersButton = UIButton(type: .custom) - filtersButton.addTarget(self, action: #selector(showSettings(_:)), for: .touchUpInside) - - if let titleLabel = filtersButton.titleLabel { - titleLabel.font = SRGFont.font(family: .text, weight: .regular, fixedSize: 16) - - // Trick to avoid incorrect truncation when Bold text has been enabled in system settings - // See https://developer.apple.com/forums/thread/125492 - titleLabel.lineBreakMode = .byClipping + + #if os(iOS) + private func updateSearchSettingsButton(for settings: MediaSearchSettings) { + guard !ApplicationConfiguration.shared.areSearchSettingsHidden else { + navigationItem.rightBarButtonItem = nil + return + } + + if filtersBarButtonItem == nil { + let filtersButton = UIButton(type: .custom) + filtersButton.addTarget(self, action: #selector(showSettings(_:)), for: .touchUpInside) + + if let titleLabel = filtersButton.titleLabel { + titleLabel.font = SRGFont.font(family: .text, weight: .regular, fixedSize: 16) + + // Trick to avoid incorrect truncation when Bold text has been enabled in system settings + // See https://developer.apple.com/forums/thread/125492 + titleLabel.lineBreakMode = .byClipping + } + filtersButton.setTitle(NSLocalizedString("Filters", comment: "Filters button title"), for: .normal) + filtersButton.setTitleColor(.srgGrayD2, for: .normal) + filtersButton.setTitleColor(.gray, for: .highlighted) + + // See https://stackoverflow.com/a/25559946/760435 + let inset: CGFloat = 2 + filtersButton.imageEdgeInsets = UIEdgeInsets(top: 0, left: -inset, bottom: 0, right: inset) + filtersButton.titleEdgeInsets = UIEdgeInsets(top: 0, left: inset, bottom: 0, right: -inset) + filtersButton.contentEdgeInsets = UIEdgeInsets(top: 0, left: inset, bottom: 0, right: inset) + + let filtersBarButtonItem = UIBarButtonItem(customView: filtersButton) + navigationItem.rightBarButtonItem = filtersBarButtonItem + self.filtersBarButtonItem = filtersBarButtonItem + } + + if let filtersButton = filtersBarButtonItem?.customView as? UIButton { + let image = !SearchViewModel.areDefaultSettings(settings) ? UIImage(resource: .filterOn) : UIImage(resource: .filterOff) + filtersButton.setImage(image, for: .normal) } - filtersButton.setTitle(NSLocalizedString("Filters", comment: "Filters button title"), for: .normal) - filtersButton.setTitleColor(.srgGrayD2, for: .normal) - filtersButton.setTitleColor(.gray, for: .highlighted) - - // See https://stackoverflow.com/a/25559946/760435 - let inset: CGFloat = 2 - filtersButton.imageEdgeInsets = UIEdgeInsets(top: 0, left: -inset, bottom: 0, right: inset) - filtersButton.titleEdgeInsets = UIEdgeInsets(top: 0, left: inset, bottom: 0, right: -inset) - filtersButton.contentEdgeInsets = UIEdgeInsets(top: 0, left: inset, bottom: 0, right: inset) - - let filtersBarButtonItem = UIBarButtonItem(customView: filtersButton) - navigationItem.rightBarButtonItem = filtersBarButtonItem - self.filtersBarButtonItem = filtersBarButtonItem - } - - if let filtersButton = filtersBarButtonItem?.customView as? UIButton { - let image = !SearchViewModel.areDefaultSettings(settings) ? UIImage(resource: .filterOn) : UIImage(resource: .filterOff) - filtersButton.setImage(image, for: .normal) } - } - - @objc private func closeKeyboard(_ sender: Any) { - searchController?.searchBar.resignFirstResponder() - } - - @objc private func showSettings(_ sender: Any) { - searchController?.searchBar.resignFirstResponder() - - let settingsViewController = SearchSettingsNavigationViewController(model: model) - settingsViewController.modalPresentationStyle = .popover - - if let popoverPresentationController = settingsViewController.popoverPresentationController { - popoverPresentationController.backgroundColor = .play_popoverGrayBackground - popoverPresentationController.permittedArrowDirections = .any - popoverPresentationController.barButtonItem = filtersBarButtonItem - } - - present(settingsViewController, animated: true) - } -#endif - + + @objc private func closeKeyboard(_: Any) { + searchController?.searchBar.resignFirstResponder() + } + + @objc private func showSettings(_: Any) { + searchController?.searchBar.resignFirstResponder() + + let settingsViewController = SearchSettingsNavigationViewController(model: model) + settingsViewController.modalPresentationStyle = .popover + + if let popoverPresentationController = settingsViewController.popoverPresentationController { + popoverPresentationController.backgroundColor = .play_popoverGrayBackground + popoverPresentationController.permittedArrowDirections = .any + popoverPresentationController.barButtonItem = filtersBarButtonItem + } + + present(settingsViewController, animated: true) + } + #endif + private func reloadData(for state: SearchViewModel.State) { switch state { case .loading: @@ -283,295 +282,293 @@ final class SearchViewController: UIViewController { if !state.hasContent { let type: EmptyContentView.`Type` = model.isSearching ? .search : .searchTutorial emptyView.content = EmptyContentView(state: .empty(type: type), insets: Self.emptyViewInsets) - } - else { + } else { emptyView.content = nil } } - + DispatchQueue.global(qos: .userInteractive).async { // Can be triggered on a background thread. Layout is updated on the main thread. self.dataSource.apply(Self.snapshot(from: state)) { -#if os(iOS) - // Avoid stopping scrolling. - // See http://stackoverflow.com/a/31681037/760435 - if self.refreshControl.isRefreshing { - self.refreshControl.endRefreshing() - } -#endif + #if os(iOS) + // Avoid stopping scrolling. + // See http://stackoverflow.com/a/31681037/760435 + if self.refreshControl.isRefreshing { + self.refreshControl.endRefreshing() + } + #endif } } } - -#if os(iOS) - @objc private func pullToRefresh(_ refreshControl: RefreshControl) { - if refreshControl.isRefreshing { - refreshControl.endRefreshing() + + #if os(iOS) + @objc private func pullToRefresh(_ refreshControl: RefreshControl) { + if refreshControl.isRefreshing { + refreshControl.endRefreshing() + } + refreshTriggered = true } - refreshTriggered = true - } -#endif + #endif } // MARK: Types extension SearchViewController { -#if os(iOS) - private typealias CollectionView = DampedCollectionView -#else - private typealias CollectionView = UICollectionView -#endif + #if os(iOS) + private typealias CollectionView = DampedCollectionView + #else + private typealias CollectionView = UICollectionView + #endif } // MARK: Instantiation extension SearchViewController { @objc static func viewController() -> UIViewController { -#if os(tvOS) - let searchViewController = SearchViewController() - let searchController = UISearchController(searchResultsController: searchViewController) - searchViewController.searchController = searchController - searchController.searchResultsUpdater = searchViewController - return UISearchContainerViewController(searchController: searchController) -#else - return SearchViewController() -#endif + #if os(tvOS) + let searchViewController = SearchViewController() + let searchController = UISearchController(searchResultsController: searchViewController) + searchViewController.searchController = searchController + searchController.searchResultsUpdater = searchViewController + return UISearchContainerViewController(searchController: searchController) + #else + return SearchViewController() + #endif } } // MARK: Keyboard shorcuts #if os(iOS) -extension SearchViewController { - private var searchKeyCommand: UIKeyCommand { - let keyCommand = UIKeyCommand(input: "f", modifierFlags: .command, action: #selector(search(_:))) - keyCommand.discoverabilityTitle = NSLocalizedString("Search", comment: "Search shortcut label") - return keyCommand - } - - @objc private func search(_ commmand: UIKeyCommand) { - searchController?.searchBar.becomeFirstResponder() - } - - override var keyCommands: [UIKeyCommand]? { - return [searchKeyCommand] + extension SearchViewController { + private var searchKeyCommand: UIKeyCommand { + let keyCommand = UIKeyCommand(input: "f", modifierFlags: .command, action: #selector(search(_:))) + keyCommand.discoverabilityTitle = NSLocalizedString("Search", comment: "Search shortcut label") + return keyCommand + } + + @objc private func search(_: UIKeyCommand) { + searchController?.searchBar.becomeFirstResponder() + } + + override var keyCommands: [UIKeyCommand]? { + [searchKeyCommand] + } } -} #endif // MARK: Protocols extension SearchViewController: ContentInsets { var play_contentScrollViews: [UIScrollView]? { - return collectionView != nil ? [collectionView] : nil + collectionView != nil ? [collectionView] : nil } - + var play_paddingContentInsets: UIEdgeInsets { - return UIEdgeInsets(top: Self.layoutVerticalMargin, left: 0, bottom: Self.layoutVerticalMargin, right: 0) + UIEdgeInsets(top: Self.layoutVerticalMargin, left: 0, bottom: Self.layoutVerticalMargin, right: 0) } } #if os(iOS) -extension SearchViewController: Oriented { -} + extension SearchViewController: Oriented {} #endif extension SearchViewController: ScrollableContent { var play_scrollableView: UIScrollView? { - return collectionView + collectionView } } extension SearchViewController: SRGAnalyticsViewTracking { var srg_pageViewTitle: String { - return AnalyticsPageTitle.home.rawValue + AnalyticsPageTitle.home.rawValue } - + var srg_pageViewType: String { - return AnalyticsPageType.navigationPage.rawValue + AnalyticsPageType.navigationPage.rawValue } - + var srg_pageViewLevels: [String]? { - return [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.search.rawValue] + [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.search.rawValue] } } #if os(iOS) -extension SearchViewController: PlayApplicationNavigation { - func open(_ applicationSectionInfo: ApplicationSectionInfo) -> Bool { - guard applicationSectionInfo.applicationSection == .search else { return false } - - model.query = applicationSectionInfo.options?[ApplicationSectionOptionKey.searchQueryKey] as? String ?? "" - - var settings = MediaSearchSettings() - if let mediaType = applicationSectionInfo.options?[ApplicationSectionOptionKey.searchMediaTypeOptionKey] as? Int { - settings.mediaType = SRGMediaType(rawValue: mediaType) ?? .none - } - model.settings = settings - - searchController?.searchBar.resignFirstResponder() - return true + extension SearchViewController: PlayApplicationNavigation { + func open(_ applicationSectionInfo: ApplicationSectionInfo) -> Bool { + guard applicationSectionInfo.applicationSection == .search else { return false } + + model.query = applicationSectionInfo.options?[ApplicationSectionOptionKey.searchQueryKey] as? String ?? "" + + var settings = MediaSearchSettings() + if let mediaType = applicationSectionInfo.options?[ApplicationSectionOptionKey.searchMediaTypeOptionKey] as? Int { + settings.mediaType = SRGMediaType(rawValue: mediaType) ?? .none + } + model.settings = settings + + searchController?.searchBar.resignFirstResponder() + return true + } } -} -extension SearchViewController: TabBarActionable { - func performActiveTabAction(animated: Bool) { - collectionView?.play_scrollToTop(animated: animated) + extension SearchViewController: TabBarActionable { + func performActiveTabAction(animated: Bool) { + collectionView?.play_scrollToTop(animated: animated) + } } -} #endif extension SearchViewController: UICollectionViewDelegate { -#if os(iOS) - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - let snapshot = dataSource.snapshot() - let section = snapshot.sectionIdentifiers[indexPath.section] - let item = snapshot.itemIdentifiers(inSection: section)[indexPath.row] - switch item { - case let .media(media): - play_presentMediaPlayer(with: media, position: nil, airPlaySuggestions: true, fromPushNotification: false, animated: true, completion: nil) - case let .show(show): - guard let navigationController else { return } - let pageViewController = PageViewController(id: .show(show)) - navigationController.pushViewController(pageViewController, animated: true) - - SRGDataProvider.current!.increaseSearchResultsViewCount(for: show) - .sink { _ in } receiveValue: { _ in } - .store(in: &cancellables) - case let .topic(topic): - guard let navigationController else { return } - let pageViewController = PageViewController(id: .topic(topic)) - navigationController.pushViewController(pageViewController, animated: true) - case .loading: - break + #if os(iOS) + func collectionView(_: UICollectionView, didSelectItemAt indexPath: IndexPath) { + let snapshot = dataSource.snapshot() + let section = snapshot.sectionIdentifiers[indexPath.section] + let item = snapshot.itemIdentifiers(inSection: section)[indexPath.row] + switch item { + case let .media(media): + play_presentMediaPlayer(with: media, position: nil, airPlaySuggestions: true, fromPushNotification: false, animated: true, completion: nil) + case let .show(show): + guard let navigationController else { return } + let pageViewController = PageViewController(id: .show(show)) + navigationController.pushViewController(pageViewController, animated: true) + + SRGDataProvider.current!.increaseSearchResultsViewCount(for: show) + .sink { _ in } receiveValue: { _ in } + .store(in: &cancellables) + case let .topic(topic): + guard let navigationController else { return } + let pageViewController = PageViewController(id: .topic(topic)) + navigationController.pushViewController(pageViewController, animated: true) + case .loading: + break + } } - } - - func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { - let snapshot = dataSource.snapshot() - let section = snapshot.sectionIdentifiers[indexPath.section] - let item = snapshot.itemIdentifiers(inSection: section)[indexPath.row] - - switch item { - case let .media(media): - return ContextMenu.configuration(for: media, at: indexPath, in: self) - case let .show(show): - return ContextMenu.configuration(for: show, at: indexPath, in: self) - default: - return nil + + func collectionView(_: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point _: CGPoint) -> UIContextMenuConfiguration? { + let snapshot = dataSource.snapshot() + let section = snapshot.sectionIdentifiers[indexPath.section] + let item = snapshot.itemIdentifiers(inSection: section)[indexPath.row] + + switch item { + case let .media(media): + return ContextMenu.configuration(for: media, at: indexPath, in: self) + case let .show(show): + return ContextMenu.configuration(for: show, at: indexPath, in: self) + default: + return nil + } } - } - - func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { - ContextMenu.commitPreview(in: self, animator: animator) - } - - func collectionView(_ collectionView: UICollectionView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { - return preview(for: configuration, in: collectionView) - } - - func collectionView(_ collectionView: UICollectionView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { - return preview(for: configuration, in: collectionView) - } - - private func preview(for configuration: UIContextMenuConfiguration, in collectionView: UICollectionView) -> UITargetedPreview? { - guard let interactionView = ContextMenu.interactionView(in: collectionView, with: configuration) else { return nil } - let parameters = UIPreviewParameters() - parameters.backgroundColor = view.backgroundColor - return UITargetedPreview(view: interactionView, parameters: parameters) - } -#endif - -#if os(tvOS) - func collectionView(_ collectionView: UICollectionView, canFocusItemAt indexPath: IndexPath) -> Bool { - return false - } -#endif + + func collectionView(_: UICollectionView, willPerformPreviewActionForMenuWith _: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + ContextMenu.commitPreview(in: self, animator: animator) + } + + func collectionView(_ collectionView: UICollectionView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + preview(for: configuration, in: collectionView) + } + + func collectionView(_ collectionView: UICollectionView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + preview(for: configuration, in: collectionView) + } + + private func preview(for configuration: UIContextMenuConfiguration, in collectionView: UICollectionView) -> UITargetedPreview? { + guard let interactionView = ContextMenu.interactionView(in: collectionView, with: configuration) else { return nil } + let parameters = UIPreviewParameters() + parameters.backgroundColor = view.backgroundColor + return UITargetedPreview(view: interactionView, parameters: parameters) + } + #endif + + #if os(tvOS) + func collectionView(_: UICollectionView, canFocusItemAt _: IndexPath) -> Bool { + false + } + #endif } #if os(iOS) -// Replace search icon with a back button -// See https://betterprogramming.pub/how-to-change-the-search-icon-in-a-uisearchbar-150b775fb6c8 -extension SearchViewController: UISearchBarDelegate { - func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { - guard let searchBar = searchBar as? SearchBar, let textField = searchBar.textField else { return } - defaultLeftView = textField.leftView - - let button = UIButton(type: .custom) - button.setImage(UIImage(systemName: "arrow.left.circle.fill"), for: .normal) - button.addTarget(self, action: #selector(closeKeyboard(_:)), for: .touchUpInside) - button.tintColor = .secondaryLabel - button.accessibilityLabel = PlaySRGAccessibilityLocalizedString("Dismiss keyboard", comment: "Label of the search bar button to close the keyboard") - textField.leftView = button - } - - func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { - guard let searchBar = searchBar as? SearchBar, let textField = searchBar.textField else { return } - textField.leftView = defaultLeftView + // Replace search icon with a back button + // See https://betterprogramming.pub/how-to-change-the-search-icon-in-a-uisearchbar-150b775fb6c8 + extension SearchViewController: UISearchBarDelegate { + func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { + guard let searchBar = searchBar as? SearchBar, let textField = searchBar.textField else { return } + defaultLeftView = textField.leftView + + let button = UIButton(type: .custom) + button.setImage(UIImage(systemName: "arrow.left.circle.fill"), for: .normal) + button.addTarget(self, action: #selector(closeKeyboard(_:)), for: .touchUpInside) + button.tintColor = .secondaryLabel + button.accessibilityLabel = PlaySRGAccessibilityLocalizedString("Dismiss keyboard", comment: "Label of the search bar button to close the keyboard") + textField.leftView = button + } + + func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { + guard let searchBar = searchBar as? SearchBar, let textField = searchBar.textField else { return } + textField.leftView = defaultLeftView + } } -} #endif extension SearchViewController: UISearchResultsUpdating { func updateSearchResults(for searchController: UISearchController) { -#if os(iOS) - // This is a workaround for UIKit bugs. See `scrollViewDidScroll(_:)`. - guard !searchUpdateInhibited else { return } -#endif + #if os(iOS) + // This is a workaround for UIKit bugs. See `scrollViewDidScroll(_:)`. + guard !searchUpdateInhibited else { return } + #endif model.query = searchController.searchBar.text ?? "" } } extension SearchViewController: UIScrollViewDelegate { -#if os(iOS) - func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { - // Avoid the collection jumping when pulling to refresh. Only mark the refresh as being triggered. - if refreshTriggered { - model.reload(deep: true) - refreshTriggered = false + #if os(iOS) + func scrollViewDidEndDecelerating(_: UIScrollView) { + // Avoid the collection jumping when pulling to refresh. Only mark the refresh as being triggered. + if refreshTriggered { + model.reload(deep: true) + refreshTriggered = false + } } - } - - // The system default behavior does not lead to correct results when large titles are displayed. Override. - func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { - scrollView.play_scrollToTop(animated: true) - return false - } -#endif - - func scrollViewDidScroll(_ scrollView: UIScrollView) { -#if os(iOS) - if scrollView.isDragging && !scrollView.isDecelerating { - searchController?.searchBar.resignFirstResponder() + + // The system default behavior does not lead to correct results when large titles are displayed. Override. + func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { + scrollView.play_scrollToTop(animated: true) + return false } - - // TODO: This is a workaround for UIKit bugs when using a search controller with `hidesSearchBarWhenScrolling` - // and large navigation titles: - // - After entering input with the search bar expanded, scrolling down does not reveal any title in - // the collapsed navigation bar. - // - After entering input with the search bar collapsed, scrolling to the top does not reveal any large - // title in the expanded navigation bar. - // The titles are in fact there but their opacity is incorrect. To fix this bug we re-attach the same search - // controller we use to the navigation item during scrolling, forcing title updates. We first must set the - // navigation item search controller to `nil` so that a refresh is triggered, which clears the search - // bar text, an update we need to inhibit. We then restore the search criterium, which does not trigger - // any further view model reload since duplicate query updates are inhibited. This is a bit expensive so - // this should only be done when the collection view is at the top. - // - // This bug will be reported to Apple and this workaround will hopefully be removed in the future. - if let navigationController { - let navigationBarState = LayoutNavigationBarStateForNavigationController(navigationController) - if navigationBarState == .largeExpanded || navigationBarState == .largeResizing { - let searchController = navigationItem.searchController - searchUpdateInhibited = true - navigationItem.searchController = nil - navigationItem.searchController = searchController - searchUpdateInhibited = false - searchController?.searchBar.text = model.query + #endif + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + #if os(iOS) + if scrollView.isDragging, !scrollView.isDecelerating { + searchController?.searchBar.resignFirstResponder() } - } -#endif - + + // TODO: This is a workaround for UIKit bugs when using a search controller with `hidesSearchBarWhenScrolling` + // and large navigation titles: + // - After entering input with the search bar expanded, scrolling down does not reveal any title in + // the collapsed navigation bar. + // - After entering input with the search bar collapsed, scrolling to the top does not reveal any large + // title in the expanded navigation bar. + // The titles are in fact there but their opacity is incorrect. To fix this bug we re-attach the same search + // controller we use to the navigation item during scrolling, forcing title updates. We first must set the + // navigation item search controller to `nil` so that a refresh is triggered, which clears the search + // bar text, an update we need to inhibit. We then restore the search criterium, which does not trigger + // any further view model reload since duplicate query updates are inhibited. This is a bit expensive so + // this should only be done when the collection view is at the top. + // + // This bug will be reported to Apple and this workaround will hopefully be removed in the future. + if let navigationController { + let navigationBarState = LayoutNavigationBarStateForNavigationController(navigationController) + if navigationBarState == .largeExpanded || navigationBarState == .largeResizing { + let searchController = navigationItem.searchController + searchUpdateInhibited = true + navigationItem.searchController = nil + navigationItem.searchController = searchController + searchUpdateInhibited = false + searchController?.searchBar.text = model.query + } + } + #endif + if scrollView.contentSize.height > 0 { let numberOfScreens = 4 if scrollView.contentOffset.y > scrollView.contentSize.height - CGFloat(numberOfScreens) * scrollView.frame.height { @@ -585,60 +582,59 @@ extension SearchViewController: UIScrollViewDelegate { private extension SearchViewController { private static let emptyViewInsets = EdgeInsets(top: constant(iOS: 0, tvOS: 350), leading: 0, bottom: 0, trailing: 0) - + private func layoutConfiguration() -> UICollectionViewCompositionalLayoutConfiguration { let configuration = UICollectionViewCompositionalLayoutConfiguration() configuration.interSectionSpacing = constant(iOS: 35, tvOS: 70) configuration.contentInsetsReference = constant(iOS: .automatic, tvOS: .layoutMargins) return configuration } - + private func layout() -> UICollectionViewLayout { - return UICollectionViewCompositionalLayout(sectionProvider: { [weak self] sectionIndex, layoutEnvironment in + UICollectionViewCompositionalLayout(sectionProvider: { [weak self] sectionIndex, layoutEnvironment in guard let self else { return nil } let layoutWidth = layoutEnvironment.container.effectiveContentSize.width - + func sectionSupplementaryItems(for section: SearchViewModel.Section) -> [NSCollectionLayoutBoundarySupplementaryItem] { let headerSize = SectionHeaderView.size(section: section, settings: self.model.settings, layoutWidth: layoutWidth) let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top) return [header] } - + func layoutSection(for section: SearchViewModel.Section, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection { let horizontalSizeClass = layoutEnvironment.traitCollection.horizontalSizeClass - + switch section { case .medias: if horizontalSizeClass == .compact { return NSCollectionLayoutSection.horizontal(layoutWidth: layoutWidth, horizontalMargin: Self.layoutHorizontalMargin, spacing: Self.itemSpacing) { _, _ in - return MediaCellSize.fullWidth() + MediaCellSize.fullWidth() } - } - else { + } else { return NSCollectionLayoutSection.grid(layoutWidth: layoutWidth, horizontalMargin: Self.layoutHorizontalMargin, spacing: Self.itemSpacing) { layoutWidth, spacing in - return MediaCellSize.grid(layoutWidth: layoutWidth, spacing: spacing) + MediaCellSize.grid(layoutWidth: layoutWidth, spacing: spacing) } } case .mostSearchedShows, .shows: let layoutSection = NSCollectionLayoutSection.horizontal(layoutWidth: layoutWidth, horizontalMargin: Self.layoutHorizontalMargin, spacing: Self.itemSpacing) { _, _ in - return ShowCellSize.swimlane(for: .default) + ShowCellSize.swimlane(for: .default) } layoutSection.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary return layoutSection case .topics: return NSCollectionLayoutSection.grid(layoutWidth: layoutWidth, horizontalMargin: Self.layoutHorizontalMargin, spacing: Self.itemSpacing) { layoutWidth, spacing in - return TopicCellSize.grid(layoutWidth: layoutWidth, spacing: spacing) + TopicCellSize.grid(layoutWidth: layoutWidth, spacing: spacing) } case .loading: return NSCollectionLayoutSection.horizontal(layoutWidth: layoutWidth) { _, _ in - return NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(150)) + NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(150)) } } } - - let snapshot = self.dataSource.snapshot() + + let snapshot = dataSource.snapshot() let section = snapshot.sectionIdentifiers[sectionIndex] - + let layoutSection = layoutSection(for: section, layoutEnvironment: layoutEnvironment) layoutSection.boundarySupplementaryItems = sectionSupplementaryItems(for: section) return layoutSection @@ -651,7 +647,7 @@ private extension SearchViewController { private extension SearchViewController { struct ItemCell: View { let item: SearchViewModel.Item - + var body: some View { switch item { case let .media(media): @@ -673,7 +669,7 @@ private extension SearchViewController { struct SectionHeaderView: View { let section: SearchViewModel.Section let settings: MediaSearchSettings - + private static func title(for section: SearchViewModel.Section, settings: MediaSearchSettings) -> String? { switch section { case .medias: @@ -696,18 +692,17 @@ private extension SearchViewController { return nil } } - + var body: some View { if let title = Self.title(for: section, settings: settings) { HeaderView(title: title, subtitle: nil, hasDetailDisclosure: false) - } - else { + } else { Color.clear } } - + static func size(section: SearchViewModel.Section, settings: MediaSearchSettings, layoutWidth: CGFloat) -> NSCollectionLayoutSize { - return HeaderViewSize.recommended(forTitle: title(for: section, settings: settings), subtitle: nil, layoutWidth: layoutWidth) + HeaderViewSize.recommended(forTitle: title(for: section, settings: settings), subtitle: nil, layoutWidth: layoutWidth) } } } diff --git a/Application/Sources/Search/SearchViewModel.swift b/Application/Sources/Search/SearchViewModel.swift index a738019ee..0b36b513e 100644 --- a/Application/Sources/Search/SearchViewModel.swift +++ b/Application/Sources/Search/SearchViewModel.swift @@ -4,32 +4,32 @@ // License information is available from the LICENSE file. // -import SRGDataProviderCombine import Combine +import SRGDataProviderCombine // MARK: View model final class SearchViewModel: ObservableObject { @Published var query = "" @Published var settings = SearchViewModel.optimalSettings() - + @Published private(set) var state = State.loading - + private let trigger = Trigger() - + var hasDefaultSettings: Bool { - return Self.areDefaultSettings(settings) + Self.areDefaultSettings(settings) } - + init() { Publishers.CombineLatest($query.removeDuplicates(), $settings) .debounceAfterFirst(for: 0.3, scheduler: DispatchQueue.main) .map { [weak self, trigger] query, settings in - return Publishers.PublishAndRepeat(onOutputFrom: self?.reloadSignal()) { - return Self.rows(matchingQuery: query, with: settings, trigger: trigger) + Publishers.PublishAndRepeat(onOutputFrom: self?.reloadSignal()) { + Self.rows(matchingQuery: query, with: settings, trigger: trigger) .map { Self.state(from: $0.rows, suggestions: $0.suggestions) } .catch { error in - return Just(State.failed(error: error)) + Just(State.failed(error: error)) } .prepend(State.loading) } @@ -38,27 +38,27 @@ final class SearchViewModel: ObservableObject { .receive(on: DispatchQueue.main) .assign(to: &$state) } - + var isSearching: Bool { - return Self.isSearching(with: query, settings: settings) + Self.isSearching(with: query, settings: settings) } - + func reload(deep: Bool = false) { if deep || !state.hasContent { trigger.activate(for: TriggerId.reload) } } - + func loadMore() { trigger.activate(for: TriggerId.loadMore) } - + func resetSettings() { settings = Self.optimalSettings() } - + private func reloadSignal() -> AnyPublisher { - return Publishers.Merge3( + Publishers.Merge3( trigger.signal(activatedBy: TriggerId.reload), ApplicationSignal.wokenUp() .filter { [weak self] in @@ -70,21 +70,21 @@ final class SearchViewModel: ObservableObject { .throttle(for: 0.5, scheduler: DispatchQueue.main, latest: false) .eraseToAnyPublisher() } - + static func areDefaultSettings(_ settings: MediaSearchSettings) -> Bool { - return optimalSettings(from: settings) == optimalSettings() + optimalSettings(from: settings) == optimalSettings() } - + private static func isSearching(with query: String, settings: MediaSearchSettings) -> Bool { - return !query.isEmpty || !Self.areDefaultSettings(settings) + !query.isEmpty || !areDefaultSettings(settings) } - + private static func optimalSettings(from settings: MediaSearchSettings = MediaSearchSettings()) -> MediaSearchSettings { var optimalSettings = settings -#if os(tvOS) - optimalSettings.mediaType = .video - optimalSettings.suggestionsEnabled = true -#endif + #if os(tvOS) + optimalSettings.mediaType = .video + optimalSettings.suggestionsEnabled = true + #endif optimalSettings.aggregationsEnabled = false return optimalSettings } @@ -97,17 +97,16 @@ extension SearchViewModel { case loading case failed(error: Error) case loaded(rows: [Row], suggestions: [SRGSearchSuggestion]?) - + var hasContent: Bool { if case let .loaded(rows: rows, suggestions: _) = self { - return !rows.isEmpty - } - else { - return false + !rows.isEmpty + } else { + false } } } - + enum Section: Hashable { case medias case shows @@ -115,16 +114,16 @@ extension SearchViewModel { case topics case loading } - + enum Item: Hashable { case media(_ media: SRGMedia) case show(_ show: SRGShow) case topic(_ topic: SRGTopic) case loading } - + typealias Row = CollectionRow - + enum TriggerId { case loadMore case reload @@ -136,50 +135,47 @@ extension SearchViewModel { private extension SearchViewModel { static func searchSuggestion() -> AnyPublisher<(rows: [Row], suggestions: [SRGSearchSuggestion]?), Error> { if !ApplicationConfiguration.shared.areShowsUnavailable { - return Publishers.CombineLatest( + Publishers.CombineLatest( mostSearchedShows(), topics() ) .map { (rows: [$0, $1], suggestions: nil) } .eraseToAnyPublisher() - } - else { - return Just([]) + } else { + Just([]) .map { (rows: $0, suggestions: nil) } .setFailureType(to: Error.self) .eraseToAnyPublisher() } } - + static func searchResults(matchingQuery query: String, with settings: MediaSearchSettings, trigger: Trigger) -> AnyPublisher<(rows: [Row], suggestions: [SRGSearchSuggestion]?), Error> { if !ApplicationConfiguration.shared.areShowsUnavailable { if !query.isEmpty { - return Publishers.CombineLatest( + Publishers.CombineLatest( shows(matchingQuery: query, with: settings), medias(matchingQuery: query, with: settings, paginatedBy: trigger.signal(activatedBy: TriggerId.loadMore)) ) .map { (rows: [$0, $1.row], suggestions: $1.suggestions) } .eraseToAnyPublisher() - } - else { - return medias(matchingQuery: query, with: settings, paginatedBy: trigger.signal(activatedBy: TriggerId.loadMore)) + } else { + medias(matchingQuery: query, with: settings, paginatedBy: trigger.signal(activatedBy: TriggerId.loadMore)) .map { (rows: [$0.row], suggestions: $0.suggestions) } .eraseToAnyPublisher() } - } - else { - return medias(matchingQuery: query, with: nil /* Case of SWI; settings not supported */, paginatedBy: trigger.signal(activatedBy: TriggerId.loadMore)) + } else { + medias(matchingQuery: query, with: nil /* Case of SWI; settings not supported */, paginatedBy: trigger.signal(activatedBy: TriggerId.loadMore)) .map { (rows: [$0.row], suggestions: $0.suggestions) } .eraseToAnyPublisher() } } - + static func shows(matchingQuery query: String, with settings: MediaSearchSettings) -> AnyPublisher { let vendor = ApplicationConfiguration.shared.vendor let pageSize = ApplicationConfiguration.shared.detailPageSize return SRGDataProvider.current!.shows(for: vendor, matchingQuery: query, mediaType: settings.mediaType, pageSize: pageSize, paginatedBy: nil) .map { output in - return SRGDataProvider.current!.shows(withUrns: output.showUrns, pageSize: pageSize) + SRGDataProvider.current!.shows(withUrns: output.showUrns, pageSize: pageSize) .map { $0.map { Item.show($0) } } } .switchToLatest() @@ -187,24 +183,24 @@ private extension SearchViewModel { .map { Row(section: .shows, items: $0) } .eraseToAnyPublisher() } - + static func medias(matchingQuery query: String, with settings: MediaSearchSettings?, paginatedBy signal: Trigger.Signal) -> AnyPublisher<(row: Row, suggestions: [SRGSearchSuggestion]?), Error> { let vendor = ApplicationConfiguration.shared.vendor let pageSize = ApplicationConfiguration.shared.detailPageSize return SRGDataProvider.current!.medias(for: vendor, matchingQuery: query, with: settings?.requestSettings, pageSize: pageSize, paginatedBy: signal) .map { output in - return SRGDataProvider.current!.medias(withUrns: output.mediaUrns, pageSize: pageSize) + SRGDataProvider.current!.medias(withUrns: output.mediaUrns, pageSize: pageSize) .map { (items: $0.map { Item.media($0) }, suggestions: output.suggestions) } } .switchToLatest() .scan((items: [], suggestions: nil)) { - return (items: removeDuplicates(in: $0.items + $1.items), suggestions: $1.suggestions ) + (items: removeDuplicates(in: $0.items + $1.items), suggestions: $1.suggestions) } .prepend((items: [Item.loading], suggestions: nil)) .map { (row: Row(section: .medias, items: $0.items), suggestions: $0.suggestions) } .eraseToAnyPublisher() } - + static func mostSearchedShows() -> AnyPublisher { let vendor = ApplicationConfiguration.shared.vendor return SRGDataProvider.current!.mostSearchedShows(for: vendor, matching: constant(iOS: .none, tvOS: .TV)) @@ -213,7 +209,7 @@ private extension SearchViewModel { .map { Row(section: .mostSearchedShows, items: $0) } .eraseToAnyPublisher() } - + static func topics() -> AnyPublisher { let vendor = ApplicationConfiguration.shared.vendor return SRGDataProvider.current!.tvTopics(for: vendor) @@ -222,33 +218,30 @@ private extension SearchViewModel { .map { Row(section: .topics, items: $0) } .eraseToAnyPublisher() } - + static func rows(matchingQuery query: String, with settings: MediaSearchSettings, trigger: Trigger) -> AnyPublisher<(rows: [Row], suggestions: [SRGSearchSuggestion]?), Error> { - if Self.isSearching(with: query, settings: settings) { - return Self.searchResults(matchingQuery: query, with: settings, trigger: trigger) - } - else { - return Self.searchSuggestion() + if isSearching(with: query, settings: settings) { + searchResults(matchingQuery: query, with: settings, trigger: trigger) + } else { + searchSuggestion() } } - + static func isLoading(row: Row) -> Bool { - return row.items.contains { $0 == .loading } + row.items.contains { $0 == .loading } } - + static func state(from rows: [Row], suggestions: [SRGSearchSuggestion]?) -> State { let loadingRows = rows.filter { isLoading(row: $0) } if loadingRows.isEmpty { let filledRows = rows.filter { !$0.items.isEmpty } return .loaded(rows: filledRows, suggestions: suggestions) - } - else { + } else { let filledRows = rows.filter { !isLoading(row: $0) && !$0.items.isEmpty } if !filledRows.isEmpty { let rows = filledRows.appending(Row(section: .loading, items: [.loading])) return .loaded(rows: rows, suggestions: suggestions) - } - else { + } else { return .loading } } diff --git a/Application/Sources/Settings/ApplicationSettings+Common.h b/Application/Sources/Settings/ApplicationSettings+Common.h index 626c2bbd7..62507a5ab 100755 --- a/Application/Sources/Settings/ApplicationSettings+Common.h +++ b/Application/Sources/Settings/ApplicationSettings+Common.h @@ -34,11 +34,53 @@ typedef NS_ENUM(NSInteger, SettingPosterImages) { SettingPosterImagesIgnored }; +/** + * Squared image setting. + */ +typedef NS_ENUM(NSInteger, SettingSquareImages) { + /** + * Default (Firebase configuration). + */ + SettingSquareImagesDefault, + /** + * Forced square images. + */ + SettingSquareImagesForced, + /** + * Ignored square images. + */ + SettingSquareImagesIgnored +}; + +/** + * Audio homepage option setting. + */ +typedef NS_ENUM(NSInteger, SettingAudioHomepageOption) { + /** + * Default (Firebase configuration). + */ + SettingAudioHomepageOptionDefault, + /** + * Force one audio curated home page usage. + */ + SettingAudioHomepageOptionCuratedOne, + /** + * Force many audio curated home pages usage. + */ + SettingAudioHomepageOptionCuratedMany, + /** + * Force many audio predefined home pages usage. + */ + SettingAudioHomepageOptionPredefinedMany +}; + OBJC_EXPORT ProgramGuideLayout ApplicationSettingProgramGuideRecentlyUsedLayout(BOOL isCompactHorizontalSizeClass); OBJC_EXPORT void ApplicationSettingSetProgramGuideRecentlyUsedLayout(ProgramGuideLayout layout); OBJC_EXPORT BOOL ApplicationSettingSectionWideSupportEnabled(void); OBJC_EXPORT SettingPosterImages ApplicationSettingPosterImages(void); +OBJC_EXPORT SettingSquareImages ApplicationSettingSquareImages(void); +OBJC_EXPORT SettingAudioHomepageOption ApplicationSettingAudioHomepageOption(void); OBJC_EXPORT NSDictionary * _Nullable ApplicationSettingGlobalParameters(void); diff --git a/Application/Sources/Settings/ApplicationSettings+Common.m b/Application/Sources/Settings/ApplicationSettings+Common.m index c85ad2a48..a626b478d 100755 --- a/Application/Sources/Settings/ApplicationSettings+Common.m +++ b/Application/Sources/Settings/ApplicationSettings+Common.m @@ -109,6 +109,51 @@ SettingPosterImages ApplicationSettingPosterImages(void) #endif } +NSValueTransformer *SettingSquareImagesTransformer(void) +{ + static NSValueTransformer *s_transformer; + static dispatch_once_t s_onceToken; + dispatch_once(&s_onceToken, ^{ + s_transformer = [NSValueTransformer mtl_valueMappingTransformerWithDictionary:@{ @"forced" : @(SettingSquareImagesForced), + @"ignored" : @(SettingSquareImagesIgnored) } + defaultValue:@(SettingSquareImagesDefault) + reverseDefaultValue:nil]; + }); + return s_transformer; +} + +SettingSquareImages ApplicationSettingSquareImages(void) +{ +#if defined(DEBUG) || defined(NIGHTLY) || defined(BETA) + return [[SettingSquareImagesTransformer() transformedValue:[NSUserDefaults.standardUserDefaults stringForKey:PlaySRGSettingSquareImages]] integerValue]; +#else + return SettingSquareImagesDefault; +#endif +} + +NSValueTransformer *SettingAudioHomepageOptionTransformer(void) +{ + static NSValueTransformer *s_transformer; + static dispatch_once_t s_onceToken; + dispatch_once(&s_onceToken, ^{ + s_transformer = [NSValueTransformer mtl_valueMappingTransformerWithDictionary:@{ @"curatedOne" : @(SettingAudioHomepageOptionCuratedOne), + @"curatedMany" : @(SettingAudioHomepageOptionCuratedMany), + @"predefinedMany" : @(SettingAudioHomepageOptionPredefinedMany) } + defaultValue:@(SettingAudioHomepageOptionDefault) + reverseDefaultValue:nil]; + }); + return s_transformer; +} + +SettingAudioHomepageOption ApplicationSettingAudioHomepageOption(void) +{ +#if defined(DEBUG) || defined(NIGHTLY) || defined(BETA) + return [[SettingAudioHomepageOptionTransformer() transformedValue:[NSUserDefaults.standardUserDefaults stringForKey:PlaySRGSettingAudioHomepageOption]] integerValue]; +#else + return SettingAudioHomepageOptionDefault; +#endif +} + NSDictionary *ApplicationSettingGlobalParameters(void) { #if defined(DEBUG) || defined(NIGHTLY) || defined(BETA) diff --git a/Application/Sources/Settings/ApplicationSettingsConstants.h b/Application/Sources/Settings/ApplicationSettingsConstants.h index 547eedce8..7e7d35d68 100644 --- a/Application/Sources/Settings/ApplicationSettingsConstants.h +++ b/Application/Sources/Settings/ApplicationSettingsConstants.h @@ -13,6 +13,8 @@ OBJC_EXPORT NSString * const PlaySRGSettingPresenterModeEnabled; OBJC_EXPORT NSString * const PlaySRGSettingStandaloneEnabled; OBJC_EXPORT NSString * const PlaySRGSettingSectionWideSupportEnabled; OBJC_EXPORT NSString * const PlaySRGSettingPosterImages; +OBJC_EXPORT NSString * const PlaySRGSettingSquareImages; +OBJC_EXPORT NSString * const PlaySRGSettingAudioHomepageOption; OBJC_EXPORT NSString * const PlaySRGSettingAutoplayEnabled; OBJC_EXPORT NSString * const PlaySRGSettingBackgroundVideoPlaybackEnabled; OBJC_EXPORT NSString * const PlaySRGSettingSubtitleAvailabilityDisplayed; diff --git a/Application/Sources/Settings/ApplicationSettingsConstants.m b/Application/Sources/Settings/ApplicationSettingsConstants.m index a40ebcaca..0d3e37216 100644 --- a/Application/Sources/Settings/ApplicationSettingsConstants.m +++ b/Application/Sources/Settings/ApplicationSettingsConstants.m @@ -11,6 +11,8 @@ NSString * const PlaySRGSettingStandaloneEnabled = @"PlaySRGSettingStandaloneEnabled"; NSString * const PlaySRGSettingSectionWideSupportEnabled = @"PlaySRGSettingSectionWideSupportEnabled"; NSString * const PlaySRGSettingPosterImages = @"PlaySRGSettingPosterImages"; +NSString * const PlaySRGSettingSquareImages = @"PlaySRGSettingSquareImages"; +NSString * const PlaySRGSettingAudioHomepageOption = @"PlaySRGSettingAudioHomepageOption"; NSString * const PlaySRGSettingAutoplayEnabled = @"PlaySRGSettingAutoplayEnabled"; NSString * const PlaySRGSettingBackgroundVideoPlaybackEnabled = @"PlaySRGSettingBackgroundVideoPlaybackEnabled"; NSString * const PlaySRGSettingSubtitleAvailabilityDisplayed = @"PlaySRGSettingSubtitleAvailabilityDisplayed"; diff --git a/Application/Sources/Settings/AudioHomepageOption.swift b/Application/Sources/Settings/AudioHomepageOption.swift new file mode 100644 index 000000000..00275c0b8 --- /dev/null +++ b/Application/Sources/Settings/AudioHomepageOption.swift @@ -0,0 +1,31 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import Foundation + +enum AudioHomepageOption: String, CaseIterable, Identifiable { + case `default` + case curatedOne + case curatedMany + case predefinedMany + + var id: Self { + self + } + + var description: String { + switch self { + case .curatedOne: + NSLocalizedString("One curated page (PAC Audio)", comment: "One curated audio homepage option setting state") + case .curatedMany: + NSLocalizedString("Many curated pages (PAC landing pages)", comment: "Many curated audio homepages option setting state") + case .predefinedMany: + NSLocalizedString("Many predefined pages", comment: "Many predefined audio homepage option setting state") + case .default: + NSLocalizedString("Default (current configuration)", comment: "Audio homepage option setting state") + } + } +} diff --git a/Application/Sources/Settings/FeaturesView.swift b/Application/Sources/Settings/FeaturesView.swift index ac115ed92..05c023df4 100644 --- a/Application/Sources/Settings/FeaturesView.swift +++ b/Application/Sources/Settings/FeaturesView.swift @@ -12,7 +12,7 @@ import SwiftUI struct FeaturesView: View { @State private var selectedOnboarding: Onboarding? - + var body: some View { List { ForEach(Onboarding.onboardings) { onboarding in @@ -31,10 +31,10 @@ struct FeaturesView: View { } .tracked(withTitle: analyticsPageTitle, type: AnalyticsPageType.help.rawValue, levels: analyticsPageLevels) } - + private struct OnboardingCell: View { let onboarding: Onboarding - + var body: some View { HStack { Image(decorative: onboarding.iconName) @@ -49,11 +49,11 @@ struct FeaturesView: View { private extension FeaturesView { private var analyticsPageTitle: String { - return AnalyticsPageTitle.features.rawValue + AnalyticsPageTitle.features.rawValue } - + private var analyticsPageLevels: [String]? { - return [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.application.rawValue] + [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.application.rawValue] } } diff --git a/Application/Sources/Settings/PosterImages.swift b/Application/Sources/Settings/PosterImages.swift index 8e485f972..f0587eba3 100644 --- a/Application/Sources/Settings/PosterImages.swift +++ b/Application/Sources/Settings/PosterImages.swift @@ -10,19 +10,19 @@ enum PosterImages: String, CaseIterable, Identifiable { case `default` case forced case ignored - + var id: Self { - return self + self } - + var description: String { switch self { case .forced: - return NSLocalizedString("Force", comment: "Poster images setting state") + NSLocalizedString("Force", comment: "Poster images setting state") case .ignored: - return NSLocalizedString("Ignore", comment: "Poster images setting state") - case .`default`: - return NSLocalizedString("Default (current configuration)", comment: "Poster images setting state") + NSLocalizedString("Ignore", comment: "Poster images setting state") + case .default: + NSLocalizedString("Default (current configuration)", comment: "Poster images setting state") } } } diff --git a/Application/Sources/Settings/Service.swift b/Application/Sources/Settings/Service.swift index 21233f7a7..63f21eccf 100644 --- a/Application/Sources/Settings/Service.swift +++ b/Application/Sources/Settings/Service.swift @@ -11,77 +11,78 @@ struct Service: Identifiable, Equatable { let id: String let name: String let url: URL - + static var production = Self( id: "production", name: NSLocalizedString("Production", comment: "Server setting name"), url: SRGIntegrationLayerProductionServiceURL() ) - + static var stage = Self( id: "stage", name: NSLocalizedString("Stage", comment: "Server setting name"), url: SRGIntegrationLayerStagingServiceURL() ) - + static var test = Self( id: "test", name: NSLocalizedString("Test", comment: "Server setting name"), url: SRGIntegrationLayerTestServiceURL() ) - + static var mmf = Self( id: "play mmf", name: "Play MMF", url: mmfUrl ) - + private static var mmfUrl: URL = { guard let mmfUrlString = Bundle.main.object(forInfoDictionaryKey: "PlayMMFServiceURL") as? String, - !mmfUrlString.isEmpty else { + !mmfUrlString.isEmpty + else { return URL(string: "https://play-mmf.herokuapp.com")! } return URL(string: mmfUrlString)! }() - + static var samProduction = Self( id: "sam production", name: "SAM \(NSLocalizedString("Production", comment: "Server setting name"))", url: SRGIntegrationLayerProductionServiceURL().appendingPathComponent("sam") ) - + static var samStage = Self( id: "sam stage", name: "SAM \(NSLocalizedString("Stage", comment: "Server setting name"))", url: SRGIntegrationLayerStagingServiceURL().appendingPathComponent("sam") ) - + static var samTest = Self( id: "sam test", name: "SAM \(NSLocalizedString("Test", comment: "Server setting name"))", url: SRGIntegrationLayerTestServiceURL().appendingPathComponent("sam") ) - + static var services: [Self] = [production, stage, test, mmf, samProduction, samStage, samTest] - + static func service(forId id: String?) -> Self { -#if DEBUG || NIGHTLY || BETA - guard let id, let server = services.first(where: { $0.id == id }) else { + #if DEBUG || NIGHTLY || BETA + guard let id, let server = services.first(where: { $0.id == id }) else { + return .production + } + return server + #else return .production - } - return server -#else - return .production -#endif + #endif } } @objc class ServiceObjC: NSObject { @objc static func name(forServiceId serviceId: String) -> String { - return Service.service(forId: serviceId).name + Service.service(forId: serviceId).name } - + @objc static func url(forServiceId serviceId: String) -> URL { - return Service.service(forId: serviceId).url + Service.service(forId: serviceId).url } } diff --git a/Application/Sources/Settings/SettingsNavigationView.swift b/Application/Sources/Settings/SettingsNavigationView.swift index 604a32ee2..e9bb794e8 100644 --- a/Application/Sources/Settings/SettingsNavigationView.swift +++ b/Application/Sources/Settings/SettingsNavigationView.swift @@ -11,7 +11,7 @@ import SwiftUI struct SettingsNavigationView: View { @FirstResponder private var firstResponder - + var body: some View { PlayNavigationView { SettingsView() @@ -34,7 +34,7 @@ struct SettingsNavigationView: View { final class SettingsNavigationViewController: NSObject { @objc static func viewController() -> UIViewController { - return SettingsNavigationHostViewController() + SettingsNavigationHostViewController() } } @@ -42,12 +42,13 @@ private final class SettingsNavigationHostViewController: UIHostingController (() -> Void) { - return { - navigateToApplicationSection(applicationSection) + + private func navigateTo(_ applicationSection: ApplicationSection) -> (() -> Void) { + { + navigateToApplicationSection(applicationSection) + } } } - } - - private struct ProfileButton: View { - @ObservedObject var model: SettingsViewModel - @State private var isAlertDisplayed = false - - private var text: String { - guard model.isLoggedIn else { return NSLocalizedString("Login", comment: "Login button on Apple TV") } - if let username = model.username { - return NSLocalizedString("Logout", comment: "Logout button on Apple TV").appending(" (\(username))") - } - else { - return NSLocalizedString("Logout", comment: "Logout button on Apple TV") + + private struct ProfileButton: View { + @ObservedObject var model: SettingsViewModel + @State private var isAlertDisplayed = false + + private var text: String { + guard model.isLoggedIn else { return NSLocalizedString("Login", comment: "Login button on Apple TV") } + if let username = model.username { + return NSLocalizedString("Logout", comment: "Logout button on Apple TV").appending(" (\(username))") + } else { + return NSLocalizedString("Logout", comment: "Logout button on Apple TV") + } } - } - - private func alert() -> Alert { - let primaryButton = Alert.Button.cancel(Text(NSLocalizedString("Cancel", comment: "Title of a cancel button"))) - let secondaryButton = Alert.Button.destructive(Text(NSLocalizedString("Logout", comment: "Logout button on Apple TV"))) { - model.logout() - } - return Alert(title: Text(NSLocalizedString("Logout", comment: "Logout alert view title on Apple TV")), - message: Text(NSLocalizedString("Playback history, favorites and content saved for later will be deleted from this Apple TV.", comment: "Message displayed when the user is about to log out")), - primaryButton: primaryButton, - secondaryButton: secondaryButton) - } - - private func action() { - if model.isLoggedIn { - isAlertDisplayed = true + + private func alert() -> Alert { + let primaryButton = Alert.Button.cancel(Text(NSLocalizedString("Cancel", comment: "Title of a cancel button"))) + let secondaryButton = Alert.Button.destructive(Text(NSLocalizedString("Logout", comment: "Logout button on Apple TV"))) { + model.logout() + } + return Alert(title: Text(NSLocalizedString("Logout", comment: "Logout alert view title on Apple TV")), + message: Text(NSLocalizedString("Playback history, favorites and content saved for later will be deleted from this Apple TV.", comment: "Message displayed when the user is about to log out")), + primaryButton: primaryButton, + secondaryButton: secondaryButton) } - else { - model.login() + + private func action() { + if model.isLoggedIn { + isAlertDisplayed = true + } else { + model.login() + } } - } - - var body: some View { - Button(action: action) { - HStack(alignment: .center) { - Spacer() - Text(text) - .foregroundColor(model.isLoggedIn ? .red : .primary) - Spacer() + + var body: some View { + Button(action: action) { + HStack(alignment: .center) { + Spacer() + Text(text) + .foregroundColor(model.isLoggedIn ? .red : .primary) + Spacer() + } } + .alert(isPresented: $isAlertDisplayed, content: alert) } - .alert(isPresented: $isAlertDisplayed, content: alert) } - } -#endif - + #endif + // MARK: Quality section - -#if os(iOS) - private struct QualitySection: View { - @AppStorage(PlaySRGSettingHDOverCellularEnabled) var isHDOverCellularEnabled = false - - var body: some View { - PlaySection { - Toggle(NSLocalizedString("HD over cellular networks", comment: "HD setting label"), isOn: $isHDOverCellularEnabled) - } header: { - Text(NSLocalizedString("Quality", comment: "Quality settings section header")) - } footer: { - Text(NSLocalizedString("To avoid possible extra costs this option can be disabled to have the highest quality played only on Wi-Fi networks.", comment: "Quality settings section footer")) + + #if os(iOS) + private struct QualitySection: View { + @AppStorage(PlaySRGSettingHDOverCellularEnabled) var isHDOverCellularEnabled = false + + var body: some View { + PlaySection { + Toggle(NSLocalizedString("HD over cellular networks", comment: "HD setting label"), isOn: $isHDOverCellularEnabled) + } header: { + Text(NSLocalizedString("Quality", comment: "Quality settings section header")) + } footer: { + Text(NSLocalizedString("To avoid possible extra costs this option can be disabled to have the highest quality played only on Wi-Fi networks.", comment: "Quality settings section footer")) + } } } - } -#endif - + #endif + // MARK: Playback section - + private struct PlaybackSection: View { @AppStorage(PlaySRGSettingAutoplayEnabled) var isAutoplayEnabled = false @AppStorage(PlaySRGSettingBackgroundVideoPlaybackEnabled) var isBackgroundPlaybackEnabled = false - + var body: some View { PlaySection { Toggle(NSLocalizedString("Autoplay", comment: "Autoplay setting label"), isOn: $isAutoplayEnabled) @@ -183,24 +182,24 @@ struct SettingsView: View { } footer: { Text(NSLocalizedString("More content is automatically played after playback of the current content ends.", comment: "Autoplay setting section footer")) } -#if os(iOS) - PlaySection { - Toggle(NSLocalizedString("Background video playback", comment: "Background video playback setting label"), isOn: $isBackgroundPlaybackEnabled) - } header: { - EmptyView() - } footer: { - Text(NSLocalizedString("Video playback continues even when you leave the application.", comment: "Background video playback setting section footer")) - } -#endif + #if os(iOS) + PlaySection { + Toggle(NSLocalizedString("Background video playback", comment: "Background video playback setting label"), isOn: $isBackgroundPlaybackEnabled) + } header: { + EmptyView() + } footer: { + Text(NSLocalizedString("Video playback continues even when you leave the application.", comment: "Background video playback setting section footer")) + } + #endif } } - + // MARK: Display section - + private struct DisplaySection: View { @AppStorage(PlaySRGSettingSubtitleAvailabilityDisplayed) var isSubtitleAvailabilityDisplayed = false @AppStorage(PlaySRGSettingAudioDescriptionAvailabilityDisplayed) var isAudioDescriptionAvailabilityDisplayed = false - + var body: some View { PlaySection { if !ApplicationConfiguration.shared.isSubtitleAvailabilityHidden { @@ -216,30 +215,30 @@ struct SettingsView: View { } } } - + // MARK: Permissions section - -#if os(iOS) - private struct PermissionsSection: View { - @ObservedObject var model: SettingsViewModel - - var body: some View { - PlaySection { - Button(NSLocalizedString("Open system settings", comment: "Label of the button opening system settings"), action: model.openSystemSettings) - } header: { - Text(NSLocalizedString("Permissions", comment: "Permissions settings section header")) - } footer: { - Text(NSLocalizedString("Local network access must be allowed for Google Cast receiver discovery.", comment: "Permissions settings section footer")) + + #if os(iOS) + private struct PermissionsSection: View { + @ObservedObject var model: SettingsViewModel + + var body: some View { + PlaySection { + Button(NSLocalizedString("Open system settings", comment: "Label of the button opening system settings"), action: model.openSystemSettings) + } header: { + Text(NSLocalizedString("Permissions", comment: "Permissions settings section header")) + } footer: { + Text(NSLocalizedString("Local network access must be allowed for Google Cast receiver discovery.", comment: "Permissions settings section footer")) + } } } - } -#endif - + #endif + // MARK: Content section - + private struct ContentSection: View { @ObservedObject var model: SettingsViewModel - + var body: some View { PlaySection { FavoritesRemovalButton(model: model) @@ -253,11 +252,11 @@ struct SettingsView: View { } } } - + private struct FavoritesRemovalButton: View { @ObservedObject var model: SettingsViewModel @State private var isAlertDisplayed = false - + private func alert() -> Alert { let primaryButton = Alert.Button.cancel(Text(NSLocalizedString("Cancel", comment: "Title of a cancel button"))) let secondaryButton = Alert.Button.destructive(Text(NSLocalizedString("Delete", comment: "Title of a delete button"))) { @@ -270,8 +269,7 @@ struct SettingsView: View { primaryButton: primaryButton, secondaryButton: secondaryButton ) - } - else { + } else { return Alert( title: Text(NSLocalizedString("Delete favorites", comment: "Title of the message displayed when the user is about to delete all favorites")), primaryButton: primaryButton, @@ -279,13 +277,13 @@ struct SettingsView: View { ) } } - + private func action() { if model.hasFavorites { isAlertDisplayed = true } } - + var body: some View { Button(action: action) { Text(NSLocalizedString("Delete favorites", comment: "Delete favorites button title")) @@ -295,11 +293,11 @@ struct SettingsView: View { .alert(isPresented: $isAlertDisplayed, content: alert) } } - + private struct HistoryRemovalButton: View { @ObservedObject var model: SettingsViewModel @State private var isAlertDisplayed = false - + private func alert() -> Alert { let primaryButton = Alert.Button.cancel(Text(NSLocalizedString("Cancel", comment: "Title of a cancel button"))) let secondaryButton = Alert.Button.destructive(Text(NSLocalizedString("Delete", comment: "Title of a delete button"))) { @@ -312,8 +310,7 @@ struct SettingsView: View { primaryButton: primaryButton, secondaryButton: secondaryButton ) - } - else { + } else { return Alert( title: Text(NSLocalizedString("Delete history", comment: "Title of the message displayed when the user is about to delete the history")), primaryButton: primaryButton, @@ -321,13 +318,13 @@ struct SettingsView: View { ) } } - + private func action() { if model.hasHistoryEntries { isAlertDisplayed = true } } - + var body: some View { Button(action: action) { Text(NSLocalizedString("Delete history", comment: "Delete history button title")) @@ -337,11 +334,11 @@ struct SettingsView: View { .alert(isPresented: $isAlertDisplayed, content: alert) } } - + private struct WatchLaterRemovalButton: View { @ObservedObject var model: SettingsViewModel @State private var isAlertDisplayed = false - + private func alert() -> Alert { let primaryButton = Alert.Button.cancel(Text(NSLocalizedString("Cancel", comment: "Title of a cancel button"))) let secondaryButton = Alert.Button.destructive(Text(NSLocalizedString("Delete", comment: "Title of a delete button"))) { @@ -354,8 +351,7 @@ struct SettingsView: View { primaryButton: primaryButton, secondaryButton: secondaryButton ) - } - else { + } else { return Alert( title: Text(NSLocalizedString("Delete content saved for later", comment: "Title of the message displayed when the user is about to delete content saved for later")), primaryButton: primaryButton, @@ -363,13 +359,13 @@ struct SettingsView: View { ) } } - + private func action() { if model.hasWatchLaterItems { isAlertDisplayed = true } } - + var body: some View { Button(action: action) { Text(NSLocalizedString("Delete content saved for later", comment: "Title of the button to delete content saved for later")) @@ -380,12 +376,12 @@ struct SettingsView: View { } } } - + // MARK: Privacy section - + private struct PrivacySection: View { @ObservedObject var model: SettingsViewModel - + var body: some View { PlaySection { if let showDataProtection = model.showDataProtection { @@ -399,62 +395,62 @@ struct SettingsView: View { } } } - + // MARK: Information section - + private struct InformationSection: View { @ObservedObject var model: SettingsViewModel - + var body: some View { PlaySection { -#if os(iOS) - NavigationLink { - FeaturesView() - .navigationBarTitleDisplayMode(.inline) - } label: { - Text(NSLocalizedString("Features", comment: "Label of the button display the features")) - } - NavigationLink { - WhatsNewView(url: model.whatsNewURL) - .navigationBarTitleDisplayMode(.inline) - } label: { - Text(NSLocalizedString("What's new", comment: "Label of the button to display what's new information")) - } - if let showImpressum = model.showImpressum { - Button(NSLocalizedString("Help and impressum", comment: "Label of the button to display help and impressum"), action: showImpressum) - } - if let showTermsAndConditions = model.showTermsAndConditions { - Button(NSLocalizedString("Terms and conditions", comment: "Label of the button to display terms and conditions"), action: showTermsAndConditions) - } - if let showSourceCode = model.showSourceCode { - Button(NSLocalizedString("Source code", comment: "Label of the button to access the source code"), action: showSourceCode) - } -#if NIGHTLY || BETA - if let switchVersion = model.switchVersion { - Button("\(NSLocalizedString("Switch version", comment: "Label of the button to open Apple TestFlight application and see other testable builds")) (TestFlight)", action: switchVersion) - } -#else - if let becomeBetaTester = model.becomeBetaTester { - let title = Bundle.main.play_isTestFlightDistribution ? - "\(NSLocalizedString("Switch version", comment: "Label of the button to open Apple TestFlight application and see other testable builds")) (TestFlight)" : - NSLocalizedString("Become a beta tester", comment: "Label of the button to become beta tester") - Button(title, action: becomeBetaTester) - } -#endif -#else - if let switchVersion = model.switchVersion { - Button("\(NSLocalizedString("Switch version", comment: "Label of the button to open Apple TestFlight application and see other testable builds")) (TestFlight)", action: switchVersion) - } -#endif + #if os(iOS) + NavigationLink { + FeaturesView() + .navigationBarTitleDisplayMode(.inline) + } label: { + Text(NSLocalizedString("Features", comment: "Label of the button display the features")) + } + NavigationLink { + WhatsNewView(url: model.whatsNewURL) + .navigationBarTitleDisplayMode(.inline) + } label: { + Text(NSLocalizedString("What's new", comment: "Label of the button to display what's new information")) + } + if let showImpressum = model.showImpressum { + Button(NSLocalizedString("Help and impressum", comment: "Label of the button to display help and impressum"), action: showImpressum) + } + if let showTermsAndConditions = model.showTermsAndConditions { + Button(NSLocalizedString("Terms and conditions", comment: "Label of the button to display terms and conditions"), action: showTermsAndConditions) + } + if let showSourceCode = model.showSourceCode { + Button(NSLocalizedString("Source code", comment: "Label of the button to access the source code"), action: showSourceCode) + } + #if NIGHTLY || BETA + if let switchVersion = model.switchVersion { + Button("\(NSLocalizedString("Switch version", comment: "Label of the button to open Apple TestFlight application and see other testable builds")) (TestFlight)", action: switchVersion) + } + #else + if let becomeBetaTester = model.becomeBetaTester { + let title = Bundle.main.play_isTestFlightDistribution ? + "\(NSLocalizedString("Switch version", comment: "Label of the button to open Apple TestFlight application and see other testable builds")) (TestFlight)" : + NSLocalizedString("Become a beta tester", comment: "Label of the button to become beta tester") + Button(title, action: becomeBetaTester) + } + #endif + #else + if let switchVersion = model.switchVersion { + Button("\(NSLocalizedString("Switch version", comment: "Label of the button to open Apple TestFlight application and see other testable builds")) (TestFlight)", action: switchVersion) + } + #endif VersionCell(model: model) } header: { Text(NSLocalizedString("Information", comment: "Information section header")) } } - + fileprivate struct VersionCell: View { @ObservedObject var model: SettingsViewModel - + var body: some View { ListItem { HStack { @@ -469,409 +465,541 @@ struct SettingsView: View { } } } - -#if os(tvOS) - // MARK: Help and Contact section - - private struct HelpAndContactSection: View { - @ObservedObject var model: SettingsViewModel - - var body: some View { - PlaySection { - if let showSupportInformation = model.showSupportInformation { - SupportInformationButton(showSupportInformation: showSupportInformation) + + #if os(tvOS) + + // MARK: Help and Contact section + + private struct HelpAndContactSection: View { + @ObservedObject var model: SettingsViewModel + + var body: some View { + PlaySection { + if let showSupportInformation = model.showSupportInformation { + SupportInformationButton(showSupportInformation: showSupportInformation) + } + } header: { + Text(NSLocalizedString("Help and contact", comment: "Help and contact section header")) } - } header: { - Text(NSLocalizedString("Help and contact", comment: "Help and contact section header")) } - } - - private struct SupportInformationButton: View { - let showSupportInformation: (() -> Void) - - @State private var isActionSheetDisplayed = false - @State private var isMailComposeDisplayed = false - - var body: some View { - Button(action: showSupportInformation) { - Text(NSLocalizedString("Report a technical issue", comment: "Label of the button to present technical issue report instructions")) + + private struct SupportInformationButton: View { + let showSupportInformation: () -> Void + + @State private var isActionSheetDisplayed = false + @State private var isMailComposeDisplayed = false + + var body: some View { + Button(action: showSupportInformation) { + Text(NSLocalizedString("Report a technical issue", comment: "Label of the button to present technical issue report instructions")) + } } } } - } -#endif - + #endif + // MARK: Advanced features section - -#if DEBUG || NIGHTLY || BETA - private struct AdvancedFeaturesSection: View { - @ObservedObject var model: SettingsViewModel - - @AppStorage(PlaySRGSettingPresenterModeEnabled) var isPresenterModeEnabled = false - @AppStorage(PlaySRGSettingStandaloneEnabled) var isStandaloneEnabled = false - @AppStorage(PlaySRGSettingSectionWideSupportEnabled) var isSectionWideSupportEnabled = false - @AppStorage(PlaySRGSettingAlwaysAskUserConsentAtLaunchEnabled) var isAlwaysAskUserConsentAtLaunchEnabled = false - - var body: some View { - PlaySection { - NextLink { - ServiceSelectionView() -#if os(iOS) - .navigationBarTitleDisplayMode(.inline) -#endif - } label: { - ServiceSelectionCell() - } - NextLink { - UserLocationSelectionView() -#if os(iOS) - .navigationBarTitleDisplayMode(.inline) -#endif - } label: { - UserLocationSelectionCell() - } - Toggle(NSLocalizedString("Presenter mode", comment: "Presenter mode setting label"), isOn: $isPresenterModeEnabled) - Toggle(NSLocalizedString("Standalone playback", comment: "Standalone playback setting label"), isOn: $isStandaloneEnabled) - Toggle(NSLocalizedString("Section wide support", comment: "Section wide support setting label"), isOn: $isSectionWideSupportEnabled) - NextLink { - PosterImagesSelectionView() -#if os(iOS) - .navigationBarTitleDisplayMode(.inline) -#endif - } label: { - PosterImagesSelectionCell() + + #if DEBUG || NIGHTLY || BETA + private struct AdvancedFeaturesSection: View { + @ObservedObject var model: SettingsViewModel + + @AppStorage(PlaySRGSettingPresenterModeEnabled) var isPresenterModeEnabled = false + @AppStorage(PlaySRGSettingStandaloneEnabled) var isStandaloneEnabled = false + @AppStorage(PlaySRGSettingSectionWideSupportEnabled) var isSectionWideSupportEnabled = false + @AppStorage(PlaySRGSettingAlwaysAskUserConsentAtLaunchEnabled) var isAlwaysAskUserConsentAtLaunchEnabled = false + + var body: some View { + PlaySection { + NextLink { + ServiceSelectionView() + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + } label: { + ServiceSelectionCell() + } + NextLink { + UserLocationSelectionView() + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + } label: { + UserLocationSelectionCell() + } + Toggle(NSLocalizedString("Presenter mode", comment: "Presenter mode setting label"), isOn: $isPresenterModeEnabled) + Toggle(NSLocalizedString("Standalone playback", comment: "Standalone playback setting label"), isOn: $isStandaloneEnabled) + Toggle(NSLocalizedString("Section wide support", comment: "Section wide support setting label"), isOn: $isSectionWideSupportEnabled) + NextLink { + PosterImagesSelectionView() + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + } label: { + PosterImagesSelectionCell() + } + #if os(iOS) || (os(tvOS) && DEBUG) + NextLink { + SquareImagesSelectionView() + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + } label: { + SquareImagesSelectionCell() + } + NextLink { + AudioHomepageSelectionView() + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + } label: { + AudioHomepageOptionSelectionCell() + } + #endif + Toggle(NSLocalizedString("Always ask user consent at launch", comment: "Always ask user consent at launch setting label"), isOn: $isAlwaysAskUserConsentAtLaunchEnabled) + #if os(iOS) && APPCENTER + VersionsAndReleaseNotesButton() + #endif + } header: { + Text(NSLocalizedString("Advanced features", comment: "Advanced features section header")) + } footer: { + Text(NSLocalizedString("This section is only available in nightly and beta versions, and won't appear in the production version.", comment: "Advanced features section footer")) } - Toggle(NSLocalizedString("Always ask user consent at launch", comment: "Always ask user consent at launch setting label"), isOn: $isAlwaysAskUserConsentAtLaunchEnabled) -#if os(iOS) && APPCENTER - VersionsAndReleaseNotesButton() -#endif - } header: { - Text(NSLocalizedString("Advanced features", comment: "Advanced features section header")) - } footer: { - Text(NSLocalizedString("This section is only available in nightly and beta versions, and won't appear in the production version.", comment: "Advanced features section footer")) } - } - - private struct ServiceSelectionCell: View { - @AppStorage(PlaySRGSettingServiceIdentifier) private var selectedServiceId: String? - - private var selectedService: Service { - return Service.service(forId: selectedServiceId) - } - - var body: some View { - HStack { - Text(NSLocalizedString("Server", comment: "Label of the button to access server selection")) - Spacer() - Text(selectedService.name) - .foregroundColor(Color.play_sectionSecondary) - .multilineTextAlignment(.trailing) - .lineLimit(2) + + private struct ServiceSelectionCell: View { + @AppStorage(PlaySRGSettingServiceIdentifier) private var selectedServiceId: String? + + private var selectedService: Service { + Service.service(forId: selectedServiceId) + } + + var body: some View { + HStack { + Text(NSLocalizedString("Server", comment: "Label of the button to access server selection")) + Spacer() + Text(selectedService.name) + .foregroundColor(Color.play_sectionSecondary) + .multilineTextAlignment(.trailing) + .lineLimit(2) + } } } - } - - private struct UserLocationSelectionCell: View { - @AppStorage(PlaySRGSettingUserLocation) private var selectedUserLocation = UserLocation.default - - var body: some View { - HStack { - Text(NSLocalizedString("User location", comment: "Label of the button for user location selection")) - Spacer() - Text(selectedUserLocation.description) - .foregroundColor(Color.play_sectionSecondary) - .multilineTextAlignment(.trailing) - .lineLimit(2) + + private struct UserLocationSelectionCell: View { + @AppStorage(PlaySRGSettingUserLocation) private var selectedUserLocation = UserLocation.default + + var body: some View { + HStack { + Text(NSLocalizedString("User location", comment: "Label of the button for user location selection")) + Spacer() + Text(selectedUserLocation.description) + .foregroundColor(Color.play_sectionSecondary) + .multilineTextAlignment(.trailing) + .lineLimit(2) + } } } - } - -#if os(iOS) && APPCENTER - private struct VersionsAndReleaseNotesButton: View { - @State private var isSheetDisplayed = false - - private var appCenterUrl: URL? { - guard let appCenterUrlString = Bundle.main.object(forInfoDictionaryKey: "AppCenterURL") as? String, !appCenterUrlString.isEmpty else { - return nil - } - return URL(string: appCenterUrlString) - } - - var body: some View { - if let appCenterUrl { - Button(NSLocalizedString("Versions and release notes", comment: "Label of the button to access release notes and download internal builds (App Center)"), action: action) - .sheet(isPresented: $isSheetDisplayed) { - SafariView(url: appCenterUrl) - .ignoresSafeArea() + + #if os(iOS) && APPCENTER + private struct VersionsAndReleaseNotesButton: View { + @State private var isSheetDisplayed = false + + private var appCenterUrl: URL? { + guard let appCenterUrlString = Bundle.main.object(forInfoDictionaryKey: "AppCenterURL") as? String, !appCenterUrlString.isEmpty else { + return nil + } + return URL(string: appCenterUrlString) + } + + var body: some View { + if let appCenterUrl { + Button(NSLocalizedString("Versions and release notes", comment: "Label of the button to access release notes and download internal builds (App Center)"), action: action) + .sheet(isPresented: $isSheetDisplayed) { + SafariView(url: appCenterUrl) + .ignoresSafeArea() + } } + } + + private func action() { + UserDefaults.standard.removeObject(forKey: "MSAppCenterPostponedTimestamp") + Distribute.checkForUpdate() + isSheetDisplayed = true + } } - } - - private func action() { - UserDefaults.standard.removeObject(forKey: "MSAppCenterPostponedTimestamp") - Distribute.checkForUpdate() - isSheetDisplayed = true - } - } -#endif - - private struct PosterImagesSelectionCell: View { - @AppStorage(PlaySRGSettingPosterImages) private var selectedPosterImages = PosterImages.default - - var body: some View { - HStack { - Text(NSLocalizedString("Poster images", comment: "Label of the button for poster image format selection")) - Spacer() - Text(selectedPosterImages.description) - .foregroundColor(Color.play_sectionSecondary) - .multilineTextAlignment(.trailing) - .lineLimit(2) + #endif + + private struct PosterImagesSelectionCell: View { + @AppStorage(PlaySRGSettingPosterImages) private var selectedPosterImages = PosterImages.default + + var body: some View { + HStack { + Text("📺 \(NSLocalizedString("Poster images", comment: "Label of the button for poster image format selection"))") + Spacer() + Text(selectedPosterImages.description) + .foregroundColor(Color.play_sectionSecondary) + .multilineTextAlignment(.trailing) + .lineLimit(2) + } } } - } - - private struct ServiceSelectionView: View { - var body: some View { - List { - ForEach(Service.services) { service in - ServiceCell(service: service) + + private struct SquareImagesSelectionCell: View { + @AppStorage(PlaySRGSettingSquareImages) private var selectedSquareImages = SquareImages.default + + var body: some View { + HStack { + Text("🎧 \(NSLocalizedString("Square images", comment: "Label of the button for Podcast square image format selection"))") + Spacer() + Text(selectedSquareImages.description) + .foregroundColor(Color.play_sectionSecondary) + .multilineTextAlignment(.trailing) + .lineLimit(2) } } - .srgFont(.body) -#if os(tvOS) - .listStyle(GroupedListStyle()) - .play_scrollClipDisabled() - .frame(maxWidth: LayoutMaxListWidth) -#endif - .navigationTitle(NSLocalizedString("Server", comment: "Server selection view title")) } - } - - private struct ServiceCell: View { - let service: Service - - @AppStorage(PlaySRGSettingServiceIdentifier) var selectedServiceId: String? - - var body: some View { - Button(action: select) { + + private struct AudioHomepageOptionSelectionCell: View { + @AppStorage(PlaySRGSettingAudioHomepageOption) private var selectedAudioHomepageOption = AudioHomepageOption.default + + var body: some View { HStack { - Text(service.name) + Text("🎧 \(NSLocalizedString("Audio home page", comment: "Label of the button for audio homepage option selection"))") Spacer() - if isSelected() { - Image(systemName: "checkmark") + Text(selectedAudioHomepageOption.description) + .foregroundColor(Color.play_sectionSecondary) + .multilineTextAlignment(.trailing) + .lineLimit(2) + } + } + } + + private struct ServiceSelectionView: View { + var body: some View { + List { + ForEach(Service.services) { service in + ServiceCell(service: service) } } + .srgFont(.body) + #if os(tvOS) + .listStyle(GroupedListStyle()) + .play_scrollClipDisabled() + .frame(maxWidth: LayoutMaxListWidth) + #endif + .navigationTitle(NSLocalizedString("Server", comment: "Server selection view title")) } - .foregroundColor(.primary) } - - private func isSelected() -> Bool { - if let selectedServiceId { - return service.id == selectedServiceId + + private struct ServiceCell: View { + let service: Service + + @AppStorage(PlaySRGSettingServiceIdentifier) var selectedServiceId: String? + + var body: some View { + Button(action: select) { + HStack { + Text(service.name) + Spacer() + if isSelected() { + Image(systemName: "checkmark") + } + } + } + .foregroundColor(.primary) + } + + private func isSelected() -> Bool { + if let selectedServiceId { + service.id == selectedServiceId + } else { + service == .production + } } - else { - return service == .production + + private func select() { + selectedServiceId = service.id } } - - private func select() { - selectedServiceId = service.id + + private struct UserLocationSelectionView: View { + var body: some View { + List { + ForEach(UserLocation.allCases) { userLocation in + LocationCell(userLocation: userLocation) + } + } + .srgFont(.body) + #if os(tvOS) + .listStyle(GroupedListStyle()) + .play_scrollClipDisabled() + .frame(maxWidth: LayoutMaxListWidth) + #endif + .navigationTitle(NSLocalizedString("User location", comment: "User location selection view title")) + } } - } - - private struct UserLocationSelectionView: View { - var body: some View { - List { - ForEach(UserLocation.allCases) { userLocation in - LocationCell(userLocation: userLocation) + + private struct LocationCell: View { + let userLocation: UserLocation + + @AppStorage(PlaySRGSettingUserLocation) private var selectedUserLocation = UserLocation.default + + var body: some View { + Button(action: select) { + HStack { + Text(userLocation.description) + Spacer() + if userLocation == selectedUserLocation { + Image(systemName: "checkmark") + } + } } + .foregroundColor(.primary) + } + + private func select() { + selectedUserLocation = userLocation } - .srgFont(.body) -#if os(tvOS) - .listStyle(GroupedListStyle()) - .play_scrollClipDisabled() - .frame(maxWidth: LayoutMaxListWidth) -#endif - .navigationTitle(NSLocalizedString("User location", comment: "User location selection view title")) } - } - - private struct LocationCell: View { - let userLocation: UserLocation - - @AppStorage(PlaySRGSettingUserLocation) private var selectedUserLocation = UserLocation.default - - var body: some View { - Button(action: select) { - HStack { - Text(userLocation.description) - Spacer() - if userLocation == selectedUserLocation { - Image(systemName: "checkmark") + + // MARK: Poster images selection + + private struct PosterImagesSelectionView: View { + var body: some View { + List { + ForEach(PosterImages.allCases) { posterImages in + PosterImagesCell(posterImages: posterImages) } } + .srgFont(.body) + #if os(tvOS) + .listStyle(GroupedListStyle()) + .play_scrollClipDisabled() + .frame(maxWidth: LayoutMaxListWidth) + #endif + .navigationTitle("📺 \(NSLocalizedString("Poster images", comment: "Poster image format selection view title"))") } - .foregroundColor(.primary) } - - private func select() { - selectedUserLocation = userLocation + + private struct PosterImagesCell: View { + let posterImages: PosterImages + + @AppStorage(PlaySRGSettingPosterImages) private var selectedPosterImages = PosterImages.default + + var body: some View { + Button(action: select) { + HStack { + Text(posterImages.description) + Spacer() + if posterImages == selectedPosterImages { + Image(systemName: "checkmark") + } + } + } + .foregroundColor(.primary) + } + + private func select() { + selectedPosterImages = posterImages + } } - } - - // MARK: Poster images selection - - private struct PosterImagesSelectionView: View { - var body: some View { - List { - ForEach(PosterImages.allCases) { posterImages in - PosterImagesCell(posterImages: posterImages) + + // MARK: Podcast square images selection + + private struct SquareImagesSelectionView: View { + var body: some View { + List { + ForEach(SquareImages.allCases) { squareImages in + SquareImagesCell(squareImages: squareImages) + } } + .srgFont(.body) + #if os(tvOS) + .listStyle(GroupedListStyle()) + .play_scrollClipDisabled() + .frame(maxWidth: LayoutMaxListWidth) + #endif + .navigationTitle("🎧 \(NSLocalizedString("Square images", comment: "Podcast square image format selection view title"))") } - .srgFont(.body) -#if os(tvOS) - .listStyle(GroupedListStyle()) - .play_scrollClipDisabled() - .frame(maxWidth: LayoutMaxListWidth) -#endif - .navigationTitle(NSLocalizedString("Poster images", comment: "Poster image format selection view title")) } - } - - private struct PosterImagesCell: View { - let posterImages: PosterImages - - @AppStorage(PlaySRGSettingPosterImages) private var selectedPosterImages = PosterImages.default - - var body: some View { - Button(action: select) { - HStack { - Text(posterImages.description) - Spacer() - if posterImages == selectedPosterImages { - Image(systemName: "checkmark") + + private struct SquareImagesCell: View { + let squareImages: SquareImages + + @AppStorage(PlaySRGSettingSquareImages) private var selectedSquareImages = SquareImages.default + + var body: some View { + Button(action: select) { + HStack { + Text(squareImages.description) + Spacer() + if squareImages == selectedSquareImages { + Image(systemName: "checkmark") + } } } + .foregroundColor(.primary) + } + + private func select() { + selectedSquareImages = squareImages } - .foregroundColor(.primary) } - - private func select() { - selectedPosterImages = posterImages + + // MARK: Audio homepage option selection + + private struct AudioHomepageSelectionView: View { + var body: some View { + List { + ForEach(AudioHomepageOption.allCases) { audioHomepageOption in + AudioHomepageOptionCell(audioHomepageOption: audioHomepageOption) + } + } + .srgFont(.body) + #if os(tvOS) + .listStyle(GroupedListStyle()) + .play_scrollClipDisabled() + .frame(maxWidth: LayoutMaxListWidth) + #endif + .navigationTitle("🎧 \(NSLocalizedString("Audio home page", comment: "Audio home page selection view title"))") + } + } + + private struct AudioHomepageOptionCell: View { + let audioHomepageOption: AudioHomepageOption + + @AppStorage(PlaySRGSettingAudioHomepageOption) private var selectedAudioHomepageOption = AudioHomepageOption.default + + var body: some View { + Button(action: select) { + HStack { + Text(audioHomepageOption.description) + Spacer() + if audioHomepageOption == selectedAudioHomepageOption { + Image(systemName: "checkmark") + } + } + } + .foregroundColor(.primary) + } + + private func select() { + selectedAudioHomepageOption = audioHomepageOption + } } } - } -#endif - + #endif + // MARK: Reset section - -#if DEBUG || NIGHTLY || BETA - private struct ResetSection: View { - @ObservedObject var model: SettingsViewModel - - var body: some View { - PlaySection { - Button(NSLocalizedString("Clear web cache", comment: "Label of the button to clear the web cache"), action: model.clearWebCache) - .foregroundColor(.red) - Button(NSLocalizedString("Clear vector image cache", comment: "Label of the button to clear the vector image cache"), action: model.clearVectorImageCache) - .foregroundColor(.red) - Button(NSLocalizedString("Clear all contents", comment: "Label of the button to clear all contents"), action: model.clearAllContents) - .foregroundColor(.red) - Button(NSLocalizedString("Simulate memory warning", comment: "Label of the button to simulate a memory warning"), action: model.simulateMemoryWarning) - } header: { - Text(NSLocalizedString("Reset", comment: "Reset section header")) - } footer: { - Text(NSLocalizedString("This section is only available in nightly and beta versions, and won't appear in the production version.", comment: "Reset section footer")) + + #if DEBUG || NIGHTLY || BETA + private struct ResetSection: View { + @ObservedObject var model: SettingsViewModel + + var body: some View { + PlaySection { + Button(NSLocalizedString("Clear web cache", comment: "Label of the button to clear the web cache"), action: model.clearWebCache) + .foregroundColor(.red) + Button(NSLocalizedString("Clear vector image cache", comment: "Label of the button to clear the vector image cache"), action: model.clearVectorImageCache) + .foregroundColor(.red) + Button(NSLocalizedString("Clear all contents", comment: "Label of the button to clear all contents"), action: model.clearAllContents) + .foregroundColor(.red) + Button(NSLocalizedString("Simulate memory warning", comment: "Label of the button to simulate a memory warning"), action: model.simulateMemoryWarning) + } header: { + Text(NSLocalizedString("Reset", comment: "Reset section header")) + } footer: { + Text(NSLocalizedString("This section is only available in nightly and beta versions, and won't appear in the production version.", comment: "Reset section footer")) + } } } - } -#endif - + #endif + // MARK: Developer section - -#if os(iOS) && (DEBUG || APPCENTER) - private struct DeveloperSection: View { - var body: some View { - PlaySection { - Button(NSLocalizedString("Enable / disable FLEX", comment: "Label of the button to toggle FLEX"), action: toggleFlex) - } header: { - Text(NSLocalizedString("Developer", comment: "Developer section header")) - } footer: { - Text(NSLocalizedString("This section is only available in nightly and beta versions, and won't appear in the production version.", comment: "Reset section footer")) + + #if os(iOS) && (DEBUG || APPCENTER) + private struct DeveloperSection: View { + var body: some View { + PlaySection { + Button(NSLocalizedString("Enable / disable FLEX", comment: "Label of the button to toggle FLEX"), action: toggleFlex) + } header: { + Text(NSLocalizedString("Developer", comment: "Developer section header")) + } footer: { + Text(NSLocalizedString("This section is only available in nightly and beta versions, and won't appear in the production version.", comment: "Reset section footer")) + } + } + + private func toggleFlex() { + FLEXManager.shared.toggleExplorer() } } - - private func toggleFlex() { - FLEXManager.shared.toggleExplorer() - } - } -#endif - + #endif + // MARK: Bottom additional information section -#if DEBUG || NIGHTLY || BETA - private struct BottomAdditionalInformationSection: View { - @ObservedObject var model: SettingsViewModel - - var body: some View { - PlaySection { - if let switchVersion = model.switchVersion { - Button("\(NSLocalizedString("Switch version", comment: "Label of the button to open Apple TestFlight application and see other testable builds")) (TestFlight)", action: switchVersion) + #if DEBUG || NIGHTLY || BETA + private struct BottomAdditionalInformationSection: View { + @ObservedObject var model: SettingsViewModel + + var body: some View { + PlaySection { + if let switchVersion = model.switchVersion { + Button("\(NSLocalizedString("Switch version", comment: "Label of the button to open Apple TestFlight application and see other testable builds")) (TestFlight)", action: switchVersion) + } + InformationSection.VersionCell(model: model) + } header: { + Text(NSLocalizedString("Information", comment: "Information section header")) + } footer: { + Text(NSLocalizedString("This section is only available in nightly and beta versions, and won't appear in the production version.", comment: "Bottom additional information section footer")) } - InformationSection.VersionCell(model: model) - } header: { - Text(NSLocalizedString("Information", comment: "Information section header")) - } footer: { - Text(NSLocalizedString("This section is only available in nightly and beta versions, and won't appear in the production version.", comment: "Bottom additional information section footer")) } } - } -#endif - + #endif + // MARK: Presentation - + /** * Presents with a modal sheet on tvOS (better), with a navigation level otherwise. */ private struct NextLink: View { @ViewBuilder var destination: () -> Destination @ViewBuilder var label: () -> Label - -#if os(tvOS) - @State private var isPresented = false -#endif - + + #if os(tvOS) + @State private var isPresented = false + #endif + var body: some View { -#if os(tvOS) - Button(action: action, label: label) - .sheet(isPresented: $isPresented, content: destination) -#else - NavigationLink(destination: { - destination() - .navigationBarTitleDisplayMode(.inline) - }, label: label) -#endif - } - -#if os(tvOS) - private func action() { - isPresented = true + #if os(tvOS) + Button(action: action, label: label) + .sheet(isPresented: $isPresented, content: destination) + #else + NavigationLink(destination: { + destination() + .navigationBarTitleDisplayMode(.inline) + }, label: label) + #endif } -#endif + + #if os(tvOS) + private func action() { + isPresented = true + } + #endif } - + /** * Simple wrapper for static list items. */ private struct ListItem: View { @ViewBuilder var content: () -> Content - + var body: some View { -#if os(tvOS) - Button(action: { /* Nothing, just to make the item focusable */ }, label: content) -#else - content() -#endif + #if os(tvOS) + Button(action: { /* Nothing, just to make the item focusable */ }, label: content) + #else + content() + #endif } } } @@ -880,11 +1008,11 @@ struct SettingsView: View { private extension SettingsView { private var analyticsPageTitle: String { - return AnalyticsPageTitle.settings.rawValue + AnalyticsPageTitle.settings.rawValue } - + private var analyticsPageLevels: [String]? { - return [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.application.rawValue] + [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.application.rawValue] } } diff --git a/Application/Sources/Settings/SettingsViewModel.swift b/Application/Sources/Settings/SettingsViewModel.swift index bb08da1b9..232c63a97 100644 --- a/Application/Sources/Settings/SettingsViewModel.swift +++ b/Application/Sources/Settings/SettingsViewModel.swift @@ -14,57 +14,57 @@ import YYWebImage final class SettingsViewModel: ObservableObject { @Published private(set) var isLoggedIn = false -#if os(tvOS) - @Published private(set) var account: SRGAccount? -#endif + #if os(tvOS) + @Published private(set) var account: SRGAccount? + #endif @Published private(set) var hasFavorites = false @Published private(set) var hasHistoryEntries = false @Published private(set) var hasWatchLaterItems = false @Published private var synchronizationDate: Date? - + init() { NotificationCenter.default.weakPublisher(for: .SRGUserDataDidFinishSynchronization, object: SRGUserData.current) .map { _ in } .prepend(()) .map { SRGUserData.current!.user.synchronizationDate } .assign(to: &$synchronizationDate) - + if let identityService = SRGIdentityService.current { Self.loggedInReloadSignal(for: identityService) .prepend(()) .map { identityService.isLoggedIn } .assign(to: &$isLoggedIn) - -#if os(tvOS) - NotificationCenter.default.weakPublisher(for: .SRGIdentityServiceDidUpdateAccount, object: identityService) - .map { _ in } - .prepend(()) - .map { identityService.account } - .assign(to: &$account) -#endif + + #if os(tvOS) + NotificationCenter.default.weakPublisher(for: .SRGIdentityServiceDidUpdateAccount, object: identityService) + .map { _ in } + .prepend(()) + .map { identityService.account } + .assign(to: &$account) + #endif } - + ThrottledSignal.preferenceUpdates() .prepend(()) - // swiftlint:disable:next empty_count + // swiftlint:disable:next empty_count .map { FavoritesShowURNs().count != 0 } .assign(to: &$hasFavorites) - + ThrottledSignal.historyUpdates() .prepend(()) .map { [weak self] _ in - return SRGDataProvider.current!.historyEntriesPublisher() + SRGDataProvider.current!.historyEntriesPublisher() .map { !$0.isEmpty } .replaceError(with: self?.hasHistoryEntries ?? false) } .switchToLatest() .receive(on: DispatchQueue.main) .assign(to: &$hasHistoryEntries) - + ThrottledSignal.watchLaterUpdates() .prepend(()) .map { [weak self] _ in - return SRGDataProvider.current!.laterEntriesPublisher() + SRGDataProvider.current!.laterEntriesPublisher() .map { !$0.isEmpty } .replaceError(with: self?.hasWatchLaterItems ?? false) } @@ -72,9 +72,9 @@ final class SettingsViewModel: ObservableObject { .receive(on: DispatchQueue.main) .assign(to: &$hasWatchLaterItems) } - + private static func loggedInReloadSignal(for identityService: SRGIdentityService) -> AnyPublisher { - return Publishers.Merge3( + Publishers.Merge3( NotificationCenter.default.weakPublisher(for: .SRGIdentityServiceUserDidCancelLogin, object: identityService), NotificationCenter.default.weakPublisher(for: .SRGIdentityServiceUserDidLogin, object: identityService), NotificationCenter.default.weakPublisher(for: .SRGIdentityServiceUserDidLogout, object: identityService) @@ -83,193 +83,190 @@ final class SettingsViewModel: ObservableObject { .map { _ in } .eraseToAnyPublisher() } - + private static func string(for date: Date?) -> String { if let date { - return DateFormatter.play_relativeDateAndTime.string(from: date) + DateFormatter.play_relativeDateAndTime.string(from: date) + } else { + NSLocalizedString("Never", comment: "Text displayed when no data synchronization has been made yet") } - else { - return NSLocalizedString("Never", comment: "Text displayed when no data synchronization has been made yet") - } - } - -#if os(tvOS) - var supportsLogin: Bool { - return SRGIdentityService.current != nil - } - - var username: String? { - return account?.displayName ?? SRGIdentityService.current?.emailAddress } - - func login() { - if let opened = SRGIdentityService.current?.login(withEmailAddress: nil), opened { - SRGAnalyticsTracker.shared.trackPageView(withTitle: AnalyticsPageTitle.login.rawValue, type: AnalyticsPageType.navigationPage.rawValue, levels: [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.user.rawValue]) - - AnalyticsEvent.identity(action: .displayLogin).send() + + #if os(tvOS) + var supportsLogin: Bool { + SRGIdentityService.current != nil } - } - - func logout() { - SRGIdentityService.current?.logout() - } -#endif - + + var username: String? { + account?.displayName ?? SRGIdentityService.current?.emailAddress + } + + func login() { + if let opened = SRGIdentityService.current?.login(withEmailAddress: nil), opened { + SRGAnalyticsTracker.shared.trackPageView(withTitle: AnalyticsPageTitle.login.rawValue, type: AnalyticsPageType.navigationPage.rawValue, levels: [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.user.rawValue]) + + AnalyticsEvent.identity(action: .displayLogin).send() + } + } + + func logout() { + SRGIdentityService.current?.logout() + } + #endif + var synchronizationStatus: String? { guard isLoggedIn else { return nil } return String(format: NSLocalizedString("Last synchronization: %@", comment: "Introductory text for the most recent data synchronization date"), Self.string(for: synchronizationDate)) } - + var version: String { - return Bundle.main.play_friendlyVersionNumber + Bundle.main.play_friendlyVersionNumber } - + var whatsNewURL: URL { - return ApplicationConfiguration.shared.whatsNewURL + ApplicationConfiguration.shared.whatsNewURL } - -#if os(iOS) - func openSystemSettings() { - UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) - } - - var showImpressum: (() -> Void)? { - guard let url = ApplicationConfiguration.shared.impressumURL else { return nil } - return { - UIApplication.shared.open(url) + + #if os(iOS) + func openSystemSettings() { + UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) } - } - - var showTermsAndConditions: (() -> Void)? { - guard let url = ApplicationConfiguration.shared.termsAndConditionsURL else { return nil } - return { - UIApplication.shared.open(url) + + var showImpressum: (() -> Void)? { + guard let url = ApplicationConfiguration.shared.impressumURL else { return nil } + return { + UIApplication.shared.open(url) + } } - } - - var showSourceCode: (() -> Void)? { - guard let url = ApplicationConfiguration.shared.sourceCodeURL else { return nil } - return { - UIApplication.shared.open(url) + + var showTermsAndConditions: (() -> Void)? { + guard let url = ApplicationConfiguration.shared.termsAndConditionsURL else { return nil } + return { + UIApplication.shared.open(url) + } } - } - - var becomeBetaTester: (() -> Void)? { - guard let url = ApplicationConfiguration.shared.betaTestingURL else { return nil } - return { - UIApplication.shared.open(url) + + var showSourceCode: (() -> Void)? { + guard let url = ApplicationConfiguration.shared.sourceCodeURL else { return nil } + return { + UIApplication.shared.open(url) + } } - } -#else - var canDisplayHelpAndContactSection: Bool { - return supportEmailAdress != nil - } - - var showSupportInformation: (() -> Void)? { - guard let supportEmailAdress else { return nil } - return { - let headerText = String(format: NSLocalizedString("Please contact us at %@", comment: "Apple TV header when displayed support information"), supportEmailAdress) - let text = String(format: "%@\n\n%@", headerText, SupportInformation.generate()) - navigateToText(text) - AnalyticsEvent.openHelp(action: .technicalIssue).send() + + var becomeBetaTester: (() -> Void)? { + guard let url = ApplicationConfiguration.shared.betaTestingURL else { return nil } + return { + UIApplication.shared.open(url) + } } - } - - private var supportEmailAdress: String? { - return ApplicationConfiguration.shared.supportEmailAddress - } -#endif - + #else + var canDisplayHelpAndContactSection: Bool { + supportEmailAdress != nil + } + + var showSupportInformation: (() -> Void)? { + guard let supportEmailAdress else { return nil } + return { + let headerText = String(format: NSLocalizedString("Please contact us at %@", comment: "Apple TV header when displayed support information"), supportEmailAdress) + let text = String(format: "%@\n\n%@", headerText, SupportInformation.generate()) + navigateToText(text) + AnalyticsEvent.openHelp(action: .technicalIssue).send() + } + } + + private var supportEmailAdress: String? { + ApplicationConfiguration.shared.supportEmailAddress + } + #endif + var canDisplayPrivacySection: Bool { - return showDataProtection != nil || showPrivacySettings != nil + showDataProtection != nil || showPrivacySettings != nil } - + var showDataProtection: (() -> Void)? { -#if os(iOS) - guard let url = ApplicationConfiguration.shared.dataProtectionURL else { return nil } - return { - UIApplication.shared.open(url) - } -#else - return nil -#endif + #if os(iOS) + guard let url = ApplicationConfiguration.shared.dataProtectionURL else { return nil } + return { + UIApplication.shared.open(url) + } + #else + return nil + #endif } - + var showPrivacySettings: (() -> Void)? { guard UserConsentHelper.isConfigured else { return nil } return { UserConsentHelper.showSecondLayer() } } - + func removeFavorites() { FavoritesRemoveShows(nil) AnalyticsEvent.favorite(action: .remove, source: .button, urn: nil).send() } - + func removeHistory() { SRGUserData.current?.history.discardHistoryEntries(withUids: nil, completionBlock: { error in guard error == nil else { return } AnalyticsEvent.historyRemove(source: .button, urn: nil).send() }) } - + func removeWatchLaterItems() { SRGUserData.current?.playlists.discardPlaylistEntries(withUids: nil, fromPlaylistWithUid: SRGPlaylistUid.watchLater.rawValue, completionBlock: { error in guard error == nil else { return } AnalyticsEvent.watchLater(action: .remove, source: .button, urn: nil).send() }) } - + func clearWebCache() { URLCache.shared.removeAllCachedResponses() - + if let cache = YYWebImageManager.shared().cache { cache.memoryCache.removeAllObjects() cache.diskCache.removeAllObjects() } } - + func clearVectorImageCache() { UIImage.srg_clearVectorImageCache() } - + func clearAllContents() { clearWebCache() clearVectorImageCache() -#if os(iOS) - Download.removeAllDownloads() -#endif + #if os(iOS) + Download.removeAllDownloads() + #endif } - + var switchVersion: (() -> Void)? { guard !Bundle.main.play_isAppStoreRelease else { return nil } - + guard let appStoreAppleId = Bundle.main.object(forInfoDictionaryKey: "AppStoreAppleId") as? String, !appStoreAppleId.isEmpty else { return nil } - + if let url = URL(string: "itms-beta://beta.itunes.apple.com/v1/app/\(appStoreAppleId)"), UIApplication.shared.canOpenURL(url) { return { UIApplication.shared.open(url) } - } - else if let url = URL(string: "https://beta.itunes.apple.com/v1/app/\(appStoreAppleId)"), UIApplication.shared.canOpenURL(url) { -#if os(iOS) - return { - UIApplication.shared.open(url) - } -#else + } else if let url = URL(string: "https://beta.itunes.apple.com/v1/app/\(appStoreAppleId)"), UIApplication.shared.canOpenURL(url) { + #if os(iOS) + return { + UIApplication.shared.open(url) + } + #else + return nil + #endif + } else { return nil -#endif } - else { - return nil - } - } - -#if DEBUG || NIGHTLY || BETA - func simulateMemoryWarning() { - let selector = Selector("_p39e45r2f435o6r7837m12M34e5m6o67r8y8W9a9r66654n43i3n2g".unobfuscated()) - UIApplication.shared.perform(selector) } -#endif + + #if DEBUG || NIGHTLY || BETA + func simulateMemoryWarning() { + let selector = Selector("_p39e45r2f435o6r7837m12M34e5m6o67r8y8W9a9r66654n43i3n2g".unobfuscated()) + UIApplication.shared.perform(selector) + } + #endif } diff --git a/Application/Sources/Settings/SquareImages.swift b/Application/Sources/Settings/SquareImages.swift new file mode 100644 index 000000000..94711860c --- /dev/null +++ b/Application/Sources/Settings/SquareImages.swift @@ -0,0 +1,28 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import Foundation + +enum SquareImages: String, CaseIterable, Identifiable { + case `default` + case forced + case ignored + + var id: Self { + self + } + + var description: String { + switch self { + case .forced: + NSLocalizedString("Force", comment: "Square images setting state") + case .ignored: + NSLocalizedString("Ignore", comment: "Square images setting state") + case .default: + NSLocalizedString("Default (current configuration)", comment: "Square images setting state") + } + } +} diff --git a/Application/Sources/Settings/UserDefaults+ApplicationSettings.swift b/Application/Sources/Settings/UserDefaults+ApplicationSettings.swift index 797863e89..2246fd574 100644 --- a/Application/Sources/Settings/UserDefaults+ApplicationSettings.swift +++ b/Application/Sources/Settings/UserDefaults+ApplicationSettings.swift @@ -10,24 +10,32 @@ import Foundation // See https://stackoverflow.com/a/47856467/760435 extension UserDefaults { @objc dynamic var PlaySRGSettingSelectedLivestreamURNForChannels: [String: Any]? { - return dictionary(forKey: PlaySRG.PlaySRGSettingSelectedLivestreamURNForChannels) + dictionary(forKey: PlaySRG.PlaySRGSettingSelectedLivestreamURNForChannels) } - + @objc dynamic var PlaySRGSettingPosterImages: String? { - return string(forKey: PlaySRG.PlaySRGSettingPosterImages) + string(forKey: PlaySRG.PlaySRGSettingPosterImages) + } + + @objc dynamic var PlaySRGSettingSquareImages: String? { + string(forKey: PlaySRG.PlaySRGSettingSquareImages) + } + + @objc dynamic var PlaySRGSettingAudioHomepageOption: String? { + string(forKey: PlaySRG.PlaySRGSettingAudioHomepageOption) } - + @objc dynamic var PlaySRGSettingServiceIdentifier: String? { - return string(forKey: PlaySRG.PlaySRGSettingServiceIdentifier) + string(forKey: PlaySRG.PlaySRGSettingServiceIdentifier) } - + @objc dynamic var PlaySRGSettingUserLocation: String? { - return string(forKey: PlaySRG.PlaySRGSettingUserLocation) + string(forKey: PlaySRG.PlaySRGSettingUserLocation) } - -#if DEBUG || NIGHTLY || BETA - @objc dynamic var PlaySRGSettingAlwaysAskUserConsentAtLaunchEnabled: Bool { - return bool(forKey: PlaySRG.PlaySRGSettingAlwaysAskUserConsentAtLaunchEnabled) - } -#endif + + #if DEBUG || NIGHTLY || BETA + @objc dynamic var PlaySRGSettingAlwaysAskUserConsentAtLaunchEnabled: Bool { + bool(forKey: PlaySRG.PlaySRGSettingAlwaysAskUserConsentAtLaunchEnabled) + } + #endif } diff --git a/Application/Sources/Settings/UserLocation.swift b/Application/Sources/Settings/UserLocation.swift index 7524609cd..5f5f71cb9 100644 --- a/Application/Sources/Settings/UserLocation.swift +++ b/Application/Sources/Settings/UserLocation.swift @@ -10,19 +10,19 @@ enum UserLocation: String, CaseIterable, Identifiable { case `default` = "" case WW case CH - + var id: Self { - return self + self } - + var description: String { switch self { case .WW: - return NSLocalizedString("Outside Switzerland", comment: "User location setting state") + NSLocalizedString("Outside Switzerland", comment: "User location setting state") case .CH: - return NSLocalizedString("Ignore location", comment: "User location setting state") - case .`default`: - return NSLocalizedString("Default (IP-based location)", comment: "User location setting state") + NSLocalizedString("Ignore location", comment: "User location setting state") + case .default: + NSLocalizedString("Default (IP-based location)", comment: "User location setting state") } } } diff --git a/Application/Sources/Settings/WhatsNewView.swift b/Application/Sources/Settings/WhatsNewView.swift index c0f701bb8..60a0da419 100644 --- a/Application/Sources/Settings/WhatsNewView.swift +++ b/Application/Sources/Settings/WhatsNewView.swift @@ -11,9 +11,9 @@ import SwiftUI struct WhatsNewView: View { let url: URL - + @StateObject private var model = WhatsNewViewModel() - + var body: some View { Group { switch model.state { @@ -40,11 +40,11 @@ struct WhatsNewView: View { private extension WhatsNewView { private var analyticsPageTitle: String { - return AnalyticsPageTitle.whatsNew.rawValue + AnalyticsPageTitle.whatsNew.rawValue } - + private var analyticsPageLevels: [String]? { - return [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.application.rawValue] + [AnalyticsPageLevel.play.rawValue, AnalyticsPageLevel.application.rawValue] } } diff --git a/Application/Sources/Settings/WhatsNewViewModel.swift b/Application/Sources/Settings/WhatsNewViewModel.swift index ecb4d2316..b593efdac 100644 --- a/Application/Sources/Settings/WhatsNewViewModel.swift +++ b/Application/Sources/Settings/WhatsNewViewModel.swift @@ -12,19 +12,19 @@ import UIKit final class WhatsNewViewModel: ObservableObject { @Published var url: URL? @Published private(set) var state: State = .loading - + init() { $url .compactMap { $0 } .map { url in - return URLSession.shared.dataTaskPublisher(for: url) + URLSession.shared.dataTaskPublisher(for: url) .map(\.data) .tryMap { data in let temporaryFileUrl = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent(UUID().uuidString) .appendingPathExtension("html") try data.write(to: temporaryFileUrl) - + var urlComponents = URLComponents(url: temporaryFileUrl, resolvingAgainstBaseURL: false)! urlComponents.queryItems = [ URLQueryItem(name: "build", value: Self.build), @@ -35,25 +35,25 @@ final class WhatsNewViewModel: ObservableObject { } .map { State.loaded(localFileUrl: $0) } .catch { error in - return Just(State.failure(error: error)) + Just(State.failure(error: error)) } } .switchToLatest() .receive(on: DispatchQueue.main) .assign(to: &$state) } - + private static var build: String { - return Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as! String + Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as! String } - + private static var version: String { let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String return shortVersion.components(separatedBy: "-").first! } - + private static var ios: String { - return UIDevice.current.systemVersion + UIDevice.current.systemVersion } } diff --git a/Application/Sources/UI/Controllers/NavigationController.h b/Application/Sources/UI/Controllers/NavigationController.h index ca4ddedb0..6c59e576d 100755 --- a/Application/Sources/UI/Controllers/NavigationController.h +++ b/Application/Sources/UI/Controllers/NavigationController.h @@ -39,7 +39,7 @@ NS_ASSUME_NONNULL_BEGIN /** * Update the navigation bar, optionally branded for a radio channel. If none is provided, a default look-and-feel is applied. -*/ + */ - (void)updateWithRadioChannel:(nullable RadioChannel *)radioChannel animated:(BOOL)animated; @end diff --git a/Application/Sources/UI/Controllers/PageContainerViewController.swift b/Application/Sources/UI/Controllers/PageContainerViewController.swift index e81280e16..e99a6a0cf 100644 --- a/Application/Sources/UI/Controllers/PageContainerViewController.swift +++ b/Application/Sources/UI/Controllers/PageContainerViewController.swift @@ -4,154 +4,119 @@ // License information is available from the LICENSE file. // -import UIKit +import Combine +import Pageboy import SRGAppearance +import Tabman +import UIKit class PageContainerViewController: UIViewController { let viewControllers: [UIViewController] - + + private var tabContainerViewController: TabContainerViewController private(set) var initialPage: Int - - private var pageViewController: UIPageViewController - - private weak var tabBar: MDCTabBar! - private weak var blurView: UIVisualEffectView! - private weak var tabBarTopConstraint: NSLayoutConstraint! - + private let tabBarItems: [TMBarItem] + private weak var tabBarTopConstraint: NSLayoutConstraint? + private weak var blurView: UIVisualEffectView? + private var cancellables: Set = [] + init(viewControllers: [UIViewController], initialPage: Int) { assert(!viewControllers.isEmpty, "At least one view controller is required") - + self.viewControllers = viewControllers - if initialPage >= 0 && initialPage < viewControllers.count { + if initialPage >= 0, initialPage < viewControllers.count { self.initialPage = initialPage } else { PlayLogWarning(category: "pageViewController", message: "Invalid page. Fixed to 0") self.initialPage = 0 } - - let pageViewController = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: [.interPageSpacing: 100.0]) - self.pageViewController = pageViewController - - super.init(nibName: nil, bundle: nil) - - pageViewController.delegate = self - - // Only allow scrolling if several pages are available - if viewControllers.count > 1 { - pageViewController.dataSource = self + + tabBarItems = viewControllers.map { + if let tabBarItem = $0.tabBarItem, let image = tabBarItem.image { + let item = TMBarItem(image: image) + item.accessibilityLabel = tabBarItem.title ?? $0.title + return item + } else { + let item = TMBarItem(title: $0.title ?? "") + item.accessibilityLabel = $0.title + return item + } } - self.addChild(pageViewController) + + tabContainerViewController = TabContainerViewController() + + super.init(nibName: nil, bundle: nil) + + addChild(tabContainerViewController) } - + convenience init(viewControllers: [UIViewController]) { self.init(viewControllers: viewControllers, initialPage: 0) } - - required init?(coder: NSCoder) { + + @available(*, unavailable) + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } - + + private func configureBarView() { + let blurView = UIVisualEffectView.play_blurView + self.blurView = blurView + + let barView = TMBarView() + barView.backgroundView.style = .custom(view: blurView) + barView.layout.alignment = .centerDistributed + barView.indicator.tintColor = .white + barView.buttons.customize { button in + button.imageContentMode = .center + } + tabContainerViewController.addBar(barView, dataSource: self, at: .top) + } + override func loadView() { view = UIView(frame: UIScreen.main.bounds) view.backgroundColor = .srgGray16 - - let pageView = pageViewController.view! - view.insertSubview(pageView, at: 0) - - pageView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - pageView.topAnchor.constraint(equalTo: view.topAnchor), - pageView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - pageView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - pageView.trailingAnchor.constraint(equalTo: view.trailingAnchor) - ]) - - pageViewController.didMove(toParent: self) - - var hasImage = false - var tabBarItems = [UITabBarItem]() - viewControllers.forEach { viewController in - if let tabBarItem = viewController.tabBarItem { - tabBarItems.append(tabBarItem) - if tabBarItem.image != nil { - hasImage = true - } - } - } - - let tabBar = MDCTabBar() - tabBar.itemAppearance = hasImage ? .images : .titles - tabBar.alignment = .center - tabBar.delegate = self - tabBar.items = tabBarItems - - tabBar.tintColor = .white - tabBar.unselectedItemTintColor = .srgGray96 - tabBar.selectedItemTintColor = .white - - // Use ripple effect without color, so that there is no Material-like highlighting (we are NOT adopting Material) - tabBar.enableRippleBehavior = true - tabBar.rippleColor = .clear - - view.addSubview(tabBar) - self.tabBar = tabBar - - tabBar.translatesAutoresizingMaskIntoConstraints = false - tabBarTopConstraint = tabBar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor) - NSLayoutConstraint.activate([ - tabBarTopConstraint, - tabBar.heightAnchor.constraint(equalToConstant: 60.0), - tabBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), - tabBar.trailingAnchor.constraint(equalTo: view.trailingAnchor) - ]) - - let blurView = UIVisualEffectView.play_blurView - blurView.alpha = 0.0 - view.insertSubview(blurView, belowSubview: tabBar) - self.blurView = blurView - - blurView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - blurView.topAnchor.constraint(equalTo: tabBar.topAnchor), - blurView.bottomAnchor.constraint(equalTo: tabBar.bottomAnchor), - blurView.leadingAnchor.constraint(equalTo: tabBar.leadingAnchor), - blurView.trailingAnchor.constraint(equalTo: tabBar.trailingAnchor) - ]) - - NotificationCenter.default.addObserver(self, selector: #selector(contentSizeCategoryDidChange(_:)), name: UIContentSizeCategory.didChangeNotification, object: nil) - - self.view = view - self.updateFonts() + view = view } - + override func viewDidLoad() { super.viewDidLoad() - - tabBar.selectedItem = tabBar.items[initialPage] - let initialViewController = viewControllers[initialPage] - pageViewController.setViewControllers([initialViewController], direction: .forward, animated: false, completion: nil) - didDisplayViewController(initialViewController, animated: false) - } - - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - super.viewWillTransition(to: size, with: coordinator) - - coordinator.animate(alongsideTransition: { _ in - // viewWillTransition(to:with:) could be called before controller view is loaded. - if self.isViewLoaded { - // Force a refresh of the tab bar so that the alignment is correct after rotation - self.tabBar.alignment = .leading - self.tabBar.alignment = .center + + configureBarView() + if let tabContainerView = tabContainerViewController.view { + view.insertSubview(tabContainerView, at: 0) + + tabContainerView.translatesAutoresizingMaskIntoConstraints = false + let tabBarTopConstraint = tabContainerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor) + NSLayoutConstraint.activate([ + tabBarTopConstraint, + tabContainerView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + tabContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tabContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + self.tabBarTopConstraint = tabBarTopConstraint + } + tabContainerViewController.didMove(toParent: self) + + tabContainerViewController.dataSource = self + didDisplayViewController(tabContainerViewController.currentViewController, animated: false) + + tabContainerViewController + .updateSignal() + .debounce(for: 0.1, scheduler: DispatchQueue.main) + .sink { [weak self] _ in + guard let self else { return } + didDisplayViewController(tabContainerViewController.currentViewController, animated: false) } - }, completion: nil) + .store(in: &cancellables) } - + override var preferredStatusBarStyle: UIStatusBarStyle { - return .lightContent + .lightContent } - + override func accessibilityPerformEscape() -> Bool { - if let navigationController = navigationController, navigationController.viewControllers.count > 1 { + if let navigationController, navigationController.viewControllers.count > 1 { navigationController.popViewController(animated: true) return true } else if presentingViewController != nil { @@ -161,142 +126,67 @@ class PageContainerViewController: UIViewController { return false } } - + func switchToIndex(_ index: Int, animated: Bool) -> Bool { guard index < viewControllers.count else { return false } - - if self.isViewLoaded { - guard displayPage(at: index, animated: animated) else { return false } - - tabBar.setSelectedItem(tabBar.items[index], animated: animated) - return true - } - else { - initialPage = index - return true - } - } - - private func displayPage(at index: Int, animated: Bool) -> Bool { - guard index < viewControllers.count else { return false } - - if self.isViewLoaded { - var direction: UIPageViewController.NavigationDirection = .forward - if let currentViewController = pageViewController.viewControllers?.first { - let currentIndex = viewControllers.firstIndex(of: currentViewController)! - direction = index > currentIndex ? .forward : .reverse - } - - let newViewController = viewControllers[index] - pageViewController.setViewControllers([newViewController], direction: direction, animated: animated) - self.play_setNeedsScrollableViewUpdate() - - didDisplayViewController(newViewController, animated: animated) + + if isViewLoaded { + tabContainerViewController.scrollToPage(.at(index: index), animated: animated) + didDisplayViewController(tabContainerViewController.currentViewController, animated: animated) return true - } - else { + } else { initialPage = index return true } } - - private func updateFonts() { - let tabBarFont = SRGFont.font(.body) as UIFont - tabBar.unselectedItemTitleFont = tabBarFont - tabBar.selectedItemTitleFont = tabBarFont - } - - @objc func contentSizeCategoryDidChange(_ notification: Notification) { - updateFonts() + + func didDisplayViewController(_: UIViewController?, animated _: Bool) { + play_setNeedsScrollableViewUpdate() } - - func didDisplayViewController(_ viewController: UIViewController, animated: Bool) {} } // MARK: - Protocols extension PageContainerViewController: ContainerContentInsets { var play_additionalContentInsets: UIEdgeInsets { - return UIEdgeInsets(top: blurView.frame.height, left: 0.0, bottom: 0.0, right: 0.0) - } -} - -extension PageContainerViewController: MDCTabBarDelegate { - func tabBar(_ tabBar: MDCTabBar, didSelect item: UITabBarItem) { - if let index = tabBar.items.firstIndex(of: item) { - _ = displayPage(at: index, animated: true) - } + UIEdgeInsets(top: tabContainerViewController.barInsets.top, left: 0.0, bottom: 0.0, right: 0.0) } } extension PageContainerViewController: Oriented { var play_supportedInterfaceOrientations: UIInterfaceOrientationMask { - return .all + .all } - + var play_orientingChildViewControllers: [UIViewController] { - return pageViewController.viewControllers ?? [] + viewControllers } } extension PageContainerViewController: ScrollableContentContainer { var play_scrollableChildViewController: UIViewController? { - return pageViewController.viewControllers?.first - } - - func play_contentOffsetDidChange(inScrollableView scrollView: UIScrollView) { - let adjustedOffset = scrollView.contentOffset.y + scrollView.adjustedContentInset.top - tabBarTopConstraint.constant = max(-adjustedOffset, 0.0) - blurView.alpha = max(0.0, min(1.0, adjustedOffset / LayoutBlurActivationDistance)) + tabContainerViewController.currentViewController } } extension PageContainerViewController: SRGAnalyticsContainerViewTracking { var srg_activeChildViewControllers: [UIViewController] { - return [pageViewController] + [tabContainerViewController] } } extension PageContainerViewController: TabBarActionable { func performActiveTabAction(animated: Bool) { - if let currentViewController = pageViewController.viewControllers?.first, + if let currentViewController = tabContainerViewController.currentViewController, let actionableCurrentViewController = currentViewController as? TabBarActionable { actionableCurrentViewController.performActiveTabAction(animated: animated) } } } -extension PageContainerViewController: UIPageViewControllerDataSource { - func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { - if let index = viewControllers.firstIndex(of: viewController), index > 0 { - return viewControllers[index - 1] - } - return nil - } - - func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { - if let index = viewControllers.firstIndex(of: viewController), index < viewControllers.count - 1 { - return viewControllers[index + 1] - } - return nil - } -} - -extension PageContainerViewController: UIPageViewControllerDelegate { - func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { - if completed { - guard let newViewController = pageViewController.viewControllers?.first else { return } - guard let currentIndex = viewControllers.firstIndex(of: newViewController) else { return } - tabBar.setSelectedItem(tabBar.items[currentIndex], animated: true) - didDisplayViewController(newViewController, animated: true) - play_setNeedsScrollableViewUpdate() - } - } -} - extension UIViewController { var play_pageContainerViewController: PageContainerViewController? { - var parentViewController = self.parent + var parentViewController = parent while let viewController = parentViewController { if let pageContainerViewController = viewController as? PageContainerViewController { return pageContainerViewController @@ -306,3 +196,21 @@ extension UIViewController { return nil } } + +extension PageContainerViewController: PageboyViewControllerDataSource, TMBarDataSource { + func numberOfViewControllers(in _: Pageboy.PageboyViewController) -> Int { + viewControllers.count + } + + func viewController(for _: Pageboy.PageboyViewController, at index: Pageboy.PageboyViewController.PageIndex) -> UIViewController? { + viewControllers[index] + } + + func defaultPage(for _: Pageboy.PageboyViewController) -> Pageboy.PageboyViewController.Page? { + .at(index: initialPage) + } + + func barItem(for _: any Tabman.TMBar, at index: Int) -> any Tabman.TMBarItemable { + tabBarItems[index] + } +} diff --git a/Application/Sources/UI/Controllers/TabBarController.m b/Application/Sources/UI/Controllers/TabBarController.m index 9d22c8831..57a6eac72 100755 --- a/Application/Sources/UI/Controllers/TabBarController.m +++ b/Application/Sources/UI/Controllers/TabBarController.m @@ -270,7 +270,7 @@ - (UIViewController *)videosTabViewController image:[UIImage imageNamed:@"videos_tab"] tag:TabBarItemIdentifierVideos]; videosTabBarItem.accessibilityIdentifier = [AccessibilityIdentifierObjC identifier:AccessibilityIdentifierVideosTabBarItem].value; - + PageViewController *pageViewController = [PageViewController videosViewController]; NavigationController *videosNavigationController = [[NavigationController alloc] initWithRootViewController:pageViewController]; videosNavigationController.tabBarItem = videosTabBarItem; @@ -290,23 +290,31 @@ - (UIViewController *)audiosTabViewController { ApplicationConfiguration *applicationConfiguration = ApplicationConfiguration.sharedApplicationConfiguration; - NSArray *radioChannels = applicationConfiguration.radioHomepageChannels; - if (radioChannels.count > 1) { - UIViewController *radioChannelsViewController = [[RadioChannelsViewController alloc] initWithRadioChannels:radioChannels]; - NavigationController *audiosNavigationController = [[NavigationController alloc] initWithRootViewController:radioChannelsViewController]; - audiosNavigationController.tabBarItem = [self audiosTabBarItem]; - return audiosNavigationController; - } - else if (radioChannels.count == 1) { - RadioChannel *radioChannel = radioChannels.firstObject; - PageViewController *pageViewController = [PageViewController audiosViewControllerForRadioChannel:radioChannel]; + if (applicationConfiguration.audioContentHomepagePreferred) { + PageViewController *pageViewController = [PageViewController audiosViewController]; NavigationController *audiosNavigationController = [[NavigationController alloc] initWithRootViewController:pageViewController]; audiosNavigationController.tabBarItem = [self audiosTabBarItem]; - [audiosNavigationController updateWithRadioChannel:radioChannel animated:NO]; return audiosNavigationController; } else { - return nil; + NSArray *radioChannels = applicationConfiguration.radioHomepageChannels; + if (radioChannels.count > 1) { + UIViewController *radioChannelsViewController = [[RadioChannelsViewController alloc] initWithRadioChannels:radioChannels]; + NavigationController *audiosNavigationController = [[NavigationController alloc] initWithRootViewController:radioChannelsViewController]; + audiosNavigationController.tabBarItem = [self audiosTabBarItem]; + return audiosNavigationController; + } + else if (radioChannels.count == 1) { + RadioChannel *radioChannel = radioChannels.firstObject; + PageViewController *pageViewController = [PageViewController audiosViewControllerForRadioChannel:radioChannel]; + NavigationController *audiosNavigationController = [[NavigationController alloc] initWithRootViewController:pageViewController]; + audiosNavigationController.tabBarItem = [self audiosTabBarItem]; + [audiosNavigationController updateWithRadioChannel:radioChannel animated:NO]; + return audiosNavigationController; + } + else { + return nil; + } } } @@ -337,7 +345,7 @@ - (UIViewController *)searchTabViewController image:[UIImage imageNamed:@"search_tab"] tag:TabBarItemIdentifierSearch]; searchTabBarItem.accessibilityIdentifier = [AccessibilityIdentifierObjC identifier:AccessibilityIdentifierSearchTabBarItem].value; - + UIViewController *searchViewController = [SearchViewController viewController]; NavigationController *searchNavigationController = [[NavigationController alloc] initWithRootViewController:searchViewController]; searchNavigationController.tabBarItem = searchTabBarItem; diff --git a/Application/Sources/UI/Controllers/TabContainerViewController.swift b/Application/Sources/UI/Controllers/TabContainerViewController.swift new file mode 100644 index 000000000..22d810f1c --- /dev/null +++ b/Application/Sources/UI/Controllers/TabContainerViewController.swift @@ -0,0 +1,33 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import Combine +import Pageboy +import Tabman +import UIKit + +final class TabContainerViewController: TabmanViewController { + private let updatePublisher = PassthroughSubject() + + func updateSignal() -> AnyPublisher { + updatePublisher.eraseToAnyPublisher() + } + + override func pageboyViewController(_ pageboyViewController: PageboyViewController, didScrollTo position: CGPoint, direction: PageboyViewController.NavigationDirection, animated: Bool) { + super.pageboyViewController(pageboyViewController, didScrollTo: position, direction: direction, animated: animated) + updatePublisher.send(()) + } + + override func pageboyViewController(_ pageboyViewController: PageboyViewController, didScrollToPageAt index: PageboyViewController.PageIndex, direction: PageboyViewController.NavigationDirection, animated: Bool) { + super.pageboyViewController(pageboyViewController, didScrollToPageAt: index, direction: direction, animated: animated) + updatePublisher.send(()) + } + + override func pageboyViewController(_ pageboyViewController: PageboyViewController, didReloadWith currentViewController: UIViewController, currentPageIndex: PageboyViewController.PageIndex) { + super.pageboyViewController(pageboyViewController, didReloadWith: currentViewController, currentPageIndex: currentPageIndex) + updatePublisher.send(()) + } +} diff --git a/Application/Sources/UI/Controllers/TableRequestViewController.m b/Application/Sources/UI/Controllers/TableRequestViewController.m index 7bba792ce..1178ab2fd 100755 --- a/Application/Sources/UI/Controllers/TableRequestViewController.m +++ b/Application/Sources/UI/Controllers/TableRequestViewController.m @@ -352,7 +352,7 @@ - (void)scrollViewDidScroll:(UIScrollView *)scrollView // Start loading the next page when less than a few screen heights from the bottom static const NSInteger kNumberOfScreens = 4; if (! self.loading && ! self.lastRequestError - && scrollView.contentOffset.y > scrollView.contentSize.height - kNumberOfScreens * CGRectGetHeight(scrollView.frame)) { + && scrollView.contentOffset.y > scrollView.contentSize.height - kNumberOfScreens * CGRectGetHeight(scrollView.frame)) { [self loadNextPage]; } } diff --git a/Application/Sources/UI/Helpers/ButtonStyles.swift b/Application/Sources/UI/Helpers/ButtonStyles.swift index 9a697648f..7b6e2bb5d 100644 --- a/Application/Sources/UI/Helpers/ButtonStyles.swift +++ b/Application/Sources/UI/Helpers/ButtonStyles.swift @@ -9,89 +9,89 @@ import SwiftUI struct FlatButtonStyle: ButtonStyle { let focused: Bool let noPadding: Bool - + init(focused: Bool, noPadding: Bool = false) { self.focused = focused self.noPadding = noPadding } -#if os(tvOS) - @State private var unfocusedSize: CGSize = .zero -#endif - + #if os(tvOS) + @State private var unfocusedSize: CGSize = .zero + #endif + func makeBody(configuration: Configuration) -> some View { -#if os(tvOS) - configuration.label - .padding(.horizontal, noPadding ? 0 : 16) - .padding(.vertical, noPadding ? 0 : 12) - .background(focused ? Color.srgGray96 : Color.srgGray23) - .cornerRadius(10) - .scaleEffect(focused && !configuration.isPressed ? Self.focusedScaleFactor(for: unfocusedSize) : 1) - .animation(.easeOut(duration: 0.2), value: focused) - .readSize { size in - unfocusedSize = size - } -#else - configuration.label - .padding(.horizontal, noPadding ? 0 : 10) - .padding(.vertical, noPadding ? 0 : 8) - .background(configuration.isPressed ? Color.srgGray4A : Color.srgGray23) - .cornerRadius(LayoutStandardViewCornerRadius) -#endif + #if os(tvOS) + configuration.label + .padding(.horizontal, noPadding ? 0 : 16) + .padding(.vertical, noPadding ? 0 : 12) + .background(focused ? Color.srgGray96 : Color.srgGray23) + .cornerRadius(10) + .scaleEffect(focused && !configuration.isPressed ? Self.focusedScaleFactor(for: unfocusedSize) : 1) + .animation(.easeOut(duration: 0.2), value: focused) + .readSize { size in + unfocusedSize = size + } + #else + configuration.label + .padding(.horizontal, noPadding ? 0 : 10) + .padding(.vertical, noPadding ? 0 : 8) + .background(configuration.isPressed ? Color.srgGray4A : Color.srgGray23) + .cornerRadius(LayoutStandardViewCornerRadius) + #endif } } #if os(tvOS) -/** - * A flat card button style to replace the built-in CardButtonStyle which suffers from issues since - * tvOS 16, especially when used in heterogeneous cells displayed in a compositional layout. - */ -@available(iOS, unavailable) -struct FlatCardButtonStyle: ButtonStyle { - let focused: Bool - - @State private var unfocusedSize: CGSize = .zero - - private var shadowColor: Color { - return focused ? Color(white: 0, opacity: 0.8) : .clear - } - - func makeBody(configuration: Configuration) -> some View { - configuration.label - .background(Color(white: 1, opacity: 0.1)) - .cornerRadius(10) - .scaleEffect(focused && !configuration.isPressed ? Self.focusedScaleFactor(for: unfocusedSize) : 1) - .shadow(color: shadowColor, radius: 20, y: 20) - .animation(.easeOut(duration: 0.2), value: focused) - .readSize { size in - unfocusedSize = size - } + /** + * A flat card button style to replace the built-in CardButtonStyle which suffers from issues since + * tvOS 16, especially when used in heterogeneous cells displayed in a compositional layout. + */ + @available(iOS, unavailable) + struct FlatCardButtonStyle: ButtonStyle { + let focused: Bool + + @State private var unfocusedSize: CGSize = .zero + + private var shadowColor: Color { + focused ? Color(white: 0, opacity: 0.8) : .clear + } + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .background(Color(white: 1, opacity: 0.1)) + .cornerRadius(10) + .scaleEffect(focused && !configuration.isPressed ? Self.focusedScaleFactor(for: unfocusedSize) : 1) + .shadow(color: shadowColor, radius: 20, y: 20) + .animation(.easeOut(duration: 0.2), value: focused) + .readSize { size in + unfocusedSize = size + } + } } -} -struct TextButtonStyle: ButtonStyle { - let focused: Bool - - @State private var unfocusedSize: CGSize = .zero - - func makeBody(configuration: Configuration) -> some View { - configuration.label - .background(focused ? Color(white: 1, opacity: 0.3) : Color.clear) - .scaleEffect(focused && !configuration.isPressed ? Self.focusedScaleFactor(for: unfocusedSize) : 1) - .animation(.easeOut(duration: 0.2), value: focused) - .readSize { size in - unfocusedSize = size - } + struct TextButtonStyle: ButtonStyle { + let focused: Bool + + @State private var unfocusedSize: CGSize = .zero + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .background(focused ? Color(white: 1, opacity: 0.3) : Color.clear) + .scaleEffect(focused && !configuration.isPressed ? Self.focusedScaleFactor(for: unfocusedSize) : 1) + .animation(.easeOut(duration: 0.2), value: focused) + .readSize { size in + unfocusedSize = size + } + } } -} -private extension ButtonStyle { - static func focusedScaleFactor(for unfocusedSize: CGSize) -> CGFloat { - let maxDimension = max(unfocusedSize.width, unfocusedSize.height) - guard maxDimension != 0 else { return 1 } - return (maxDimension + 40) / maxDimension + private extension ButtonStyle { + static func focusedScaleFactor(for unfocusedSize: CGSize) -> CGFloat { + let maxDimension = max(unfocusedSize.width, unfocusedSize.height) + guard maxDimension != 0 else { return 1 } + return (maxDimension + 40) / maxDimension + } } -} #endif diff --git a/Application/Sources/UI/Helpers/ContextMenu.swift b/Application/Sources/UI/Helpers/ContextMenu.swift index 44eb866fe..13ea86653 100644 --- a/Application/Sources/UI/Helpers/ContextMenu.swift +++ b/Application/Sources/UI/Helpers/ContextMenu.swift @@ -12,40 +12,39 @@ import UIKit enum ContextMenu { // See https://github.com/SRGSSR/playsrg-apple/issues/192 private static let actionDelay = DispatchTimeInterval.seconds(1) - + static func configuration(for item: Content.Item, identifier: NSCopying? = nil, in viewController: UIViewController) -> UIContextMenuConfiguration? { switch item { case let .media(media): - return configuration(for: media, identifier: identifier, in: viewController) + configuration(for: media, identifier: identifier, in: viewController) case let .show(show): - return configuration(for: show, identifier: identifier, in: viewController) + configuration(for: show, identifier: identifier, in: viewController) case let .download(download): if let media = download.media { - return configuration(for: media, identifier: identifier, in: viewController) - } - else { - return nil + configuration(for: media, identifier: identifier, in: viewController) + } else { + nil } default: - return nil + nil } } - + static func configuration(for item: Content.Item, at indexPath: IndexPath, in viewController: UIViewController) -> UIContextMenuConfiguration? { // Build an `NSIndexPath` from the `IndexPath` argument to have an equivalent identifier conforming to `NSCopying`. - return configuration(for: item, identifier: NSIndexPath(item: indexPath.row, section: indexPath.section), in: viewController) + configuration(for: item, identifier: NSIndexPath(item: indexPath.row, section: indexPath.section), in: viewController) } - + static func interactionView(in tableView: UITableView, with configuration: UIContextMenuConfiguration) -> UIView? { guard let indexPath = configuration.identifier as? NSIndexPath else { return nil } return tableView.cellForRow(at: IndexPath(row: indexPath.row, section: indexPath.section)) } - + static func interactionView(in collectionView: UICollectionView, with configuration: UIContextMenuConfiguration) -> UIView? { guard let indexPath = configuration.identifier as? NSIndexPath else { return nil } return collectionView.cellForItem(at: IndexPath(row: indexPath.row, section: indexPath.section)) } - + static func commitPreview(in viewController: UIViewController, animator: UIContextMenuInteractionCommitAnimating) { animator.preferredCommitStyle = .pop animator.addCompletion { @@ -53,8 +52,7 @@ enum ContextMenu { if let mediaPreviewViewController = previewViewController as? MediaPreviewViewController { guard let letterboxController = mediaPreviewViewController.letterboxController else { return } viewController.play_presentMediaPlayer(from: letterboxController, withAirPlaySuggestions: true, fromPushNotification: false, animated: true, completion: nil) - } - else if let navigationController = viewController.navigationController { + } else if let navigationController = viewController.navigationController { navigationController.pushViewController(previewViewController, animated: true) } } @@ -65,21 +63,21 @@ enum ContextMenu { private extension ContextMenu { private class ActivityPopoverPresentationDelegate: NSObject, UIPopoverPresentationControllerDelegate { - func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { - return .formSheet + func adaptivePresentationStyle(for _: UIPresentationController, traitCollection _: UITraitCollection) -> UIModalPresentationStyle { + .formSheet } } - + private static let popoverPresentationDelegate = ActivityPopoverPresentationDelegate() - + private static func shareItem(_ sharingItem: SharingItem, in viewController: UIViewController) { let activityViewController = UIActivityViewController(sharingItem: sharingItem, from: .contextMenu) activityViewController.modalPresentationStyle = .popover - + let popoverPresentationController = activityViewController.popoverPresentationController popoverPresentationController?.sourceView = viewController.view popoverPresentationController?.delegate = popoverPresentationDelegate - + viewController.present(activityViewController, animated: true, completion: nil) } } @@ -88,20 +86,20 @@ private extension ContextMenu { extension ContextMenu { static func configuration(for media: SRGMedia, identifier: NSCopying?, in viewController: UIViewController) -> UIContextMenuConfiguration? { - return UIContextMenuConfiguration(identifier: identifier) { - return MediaPreviewViewController(media: media) + UIContextMenuConfiguration(identifier: identifier) { + MediaPreviewViewController(media: media) } actionProvider: { _ in - return menu(for: media, in: viewController) + menu(for: media, in: viewController) } } - + static func configuration(for media: SRGMedia, at indexPath: IndexPath, in viewController: UIViewController) -> UIContextMenuConfiguration? { // Build an `NSIndexPath` from the `IndexPath` argument to have an equivalent identifier conforming to `NSCopying`. - return configuration(for: media, identifier: NSIndexPath(item: indexPath.row, section: indexPath.section), in: viewController) + configuration(for: media, identifier: NSIndexPath(item: indexPath.row, section: indexPath.section), in: viewController) } - + static func menu(for media: SRGMedia, in viewController: UIViewController) -> UIMenu { - return UIMenu(title: "", children: [ + UIMenu(title: "", children: [ watchLaterAction(for: media), historyAction(for: media), downloadAction(for: media), @@ -109,37 +107,35 @@ extension ContextMenu { moreEpisodesAction(for: media, in: viewController) ].compactMap { $0 }) } - + private static func watchLaterAction(for media: SRGMedia) -> UIAction? { func title(for action: WatchLaterAction) -> String { if action == .add { if media.mediaType == .audio { - return NSLocalizedString("Listen later", comment: "Context menu action to add an audio to the later list") - } - else { - return NSLocalizedString("Watch later", comment: "Context menu action to add a video to the later list") + NSLocalizedString("Listen later", comment: "Context menu action to add an audio to the later list") + } else { + NSLocalizedString("Watch later", comment: "Context menu action to add a video to the later list") } - } - else { - return NSLocalizedString("Delete from \"Later\"", comment: "Context menu action to delete a media from the later list") + } else { + NSLocalizedString("Delete from \"Later\"", comment: "Context menu action to delete a media from the later list") } } - + func image(for action: WatchLaterAction) -> UIImage { - return (action == .add) ? UIImage(resource: .watchLater) : UIImage(resource: .watchLaterFull) + (action == .add) ? UIImage(resource: .watchLater) : UIImage(resource: .watchLaterFull) } - + let action = WatchLaterAllowedActionForMedia(media) guard action != .none else { return nil } - + let menuAction = UIAction(title: title(for: action), image: image(for: action)) { _ in DispatchQueue.main.asyncAfter(deadline: .now() + Self.actionDelay) { WatchLaterToggleMedia(media) { added, error in guard error == nil else { return } - + let action = added ? .add : .remove as AnalyticsListAction AnalyticsEvent.watchLater(action: action, source: .contextMenu, urn: media.urn).send() - + Banner.showWatchLaterAdded(added, forItemWithName: media.title) } } @@ -149,16 +145,16 @@ extension ContextMenu { } return menuAction } - + private static func historyAction(for media: SRGMedia) -> UIAction? { guard HistoryContainsMedia(media) else { return nil } - + let menuAction = UIAction(title: NSLocalizedString("Delete from history", comment: "Context menu action to delete a media from the history"), image: UIImage(resource: .history)) { _ in DispatchQueue.main.asyncAfter(deadline: .now() + Self.actionDelay) { HistoryRemoveMedias([media]) { error in guard error == nil else { return } - + AnalyticsEvent.historyRemove(source: .contextMenu, urn: media.urn).send() } } @@ -166,36 +162,34 @@ extension ContextMenu { menuAction.attributes = .destructive return menuAction } - + private static func downloadAction(for media: SRGMedia) -> UIAction? { guard Download.canToggle(for: media) else { return nil } - + func title(for download: Download?) -> String { if download != nil { - return NSLocalizedString("Delete from downloads", comment: "Context menu action to delete a media from the downloads") - } - else { - return NSLocalizedString("Add to downloads", comment: "Context menu action to add a media to the downloads") + NSLocalizedString("Delete from downloads", comment: "Context menu action to delete a media from the downloads") + } else { + NSLocalizedString("Add to downloads", comment: "Context menu action to add a media to the downloads") } } - + func image(for download: Download?) -> UIImage { - return download != nil ? UIImage(resource: .downloadRemove) : UIImage(resource: .download) + download != nil ? UIImage(resource: .downloadRemove) : UIImage(resource: .download) } - + let download = Download(for: media) let menuAction = UIAction(title: title(for: download), image: image(for: download)) { _ in DispatchQueue.main.asyncAfter(deadline: .now() + Self.actionDelay) { if let download { Download.removeDownloads([download]) - } - else { + } else { Download.add(for: media) } - + let action = (download == nil) ? .add : .remove as AnalyticsListAction AnalyticsEvent.download(action: action, source: .contextMenu, urn: media.urn).send() - + Banner.showDownload(download == nil, forItemWithName: media.title) } } @@ -204,7 +198,7 @@ extension ContextMenu { } return menuAction } - + private static func sharingAction(for media: SRGMedia, in viewController: UIViewController) -> UIAction? { guard let sharingItem = SharingItem(for: media, at: CMTime.zero) else { return nil } return UIAction(title: NSLocalizedString("Share", comment: "Context menu action to share a media"), @@ -212,7 +206,7 @@ extension ContextMenu { shareItem(sharingItem, in: viewController) } } - + private static func moreEpisodesAction(for media: SRGMedia, in viewController: UIViewController) -> UIAction? { guard !ApplicationConfiguration.shared.areShowsUnavailable, let show = media.show, @@ -233,47 +227,46 @@ extension ContextMenu { extension ContextMenu { static func configuration(for show: SRGShow, identifier: NSCopying?, in viewController: UIViewController) -> UIContextMenuConfiguration? { - return UIContextMenuConfiguration(identifier: identifier) { - return PageViewController(id: .show(show)) + UIContextMenuConfiguration(identifier: identifier) { + PageViewController(id: .show(show)) } actionProvider: { _ in - return menu(for: show, in: viewController) + menu(for: show, in: viewController) } } - + static func configuration(for show: SRGShow, at indexPath: IndexPath, in viewController: UIViewController) -> UIContextMenuConfiguration? { // Build an `NSIndexPath` from the `IndexPath` argument to have an equivalent identifier conforming to `NSCopying`. - return configuration(for: show, identifier: NSIndexPath(item: indexPath.row, section: indexPath.section), in: viewController) + configuration(for: show, identifier: NSIndexPath(item: indexPath.row, section: indexPath.section), in: viewController) } - + private static func menu(for show: SRGShow, in viewController: UIViewController) -> UIMenu { - return UIMenu(title: "", children: [ + UIMenu(title: "", children: [ favoriteAction(for: show), sharingAction(for: show, in: viewController) ].compactMap { $0 }) } - + private static func favoriteAction(for show: SRGShow) -> UIAction? { func title(isFavorite: Bool) -> String { if isFavorite { - return NSLocalizedString("Delete from favorites", comment: "Context menu action to delete a show from favorites") - } - else { - return NSLocalizedString("Add to favorites", comment: "Context menu action to add a show to favorites") + NSLocalizedString("Delete from favorites", comment: "Context menu action to delete a show from favorites") + } else { + NSLocalizedString("Add to favorites", comment: "Context menu action to add a show to favorites") } } - + func image(isFavorite: Bool) -> UIImage { - return isFavorite ? UIImage(resource: .favoriteFull) : UIImage(resource: .favorite) + isFavorite ? UIImage(resource: .favoriteFull) : UIImage(resource: .favorite) } - + let isFavorite = FavoritesContainsShow(show) let menuAction = UIAction(title: title(isFavorite: isFavorite), image: image(isFavorite: isFavorite)) { _ in DispatchQueue.main.asyncAfter(deadline: .now() + Self.actionDelay) { FavoritesToggleShow(show) - + let action = !isFavorite ? .add : .remove as AnalyticsListAction AnalyticsEvent.favorite(action: action, source: .contextMenu, urn: show.urn).send() - + Banner.showFavorite(!isFavorite, forItemWithName: show.title) } } @@ -282,7 +275,7 @@ extension ContextMenu { } return menuAction } - + private static func sharingAction(for show: SRGShow, in viewController: UIViewController) -> UIAction? { guard let sharingItem = SharingItem(for: show) else { return nil } return UIAction(title: NSLocalizedString("Share", comment: "Context menu action to share a show"), @@ -298,29 +291,28 @@ extension ContextMenu { @objc static func configuration(for object: AnyObject, at indexPath: NSIndexPath, in viewController: UIViewController) -> UIContextMenuConfiguration? { switch object { case let media as SRGMedia: - return ContextMenu.configuration(for: media, identifier: indexPath, in: viewController) + ContextMenu.configuration(for: media, identifier: indexPath, in: viewController) case let show as SRGShow: - return ContextMenu.configuration(for: show, identifier: indexPath, in: viewController) + ContextMenu.configuration(for: show, identifier: indexPath, in: viewController) case let download as Download: if let media = download.media { - return ContextMenu.configuration(for: media, identifier: indexPath, in: viewController) - } - else { - return nil + ContextMenu.configuration(for: media, identifier: indexPath, in: viewController) + } else { + nil } default: - return nil + nil } } - + @objc static func interactionView(inTableView tableView: UITableView, with configuration: UIContextMenuConfiguration) -> UIView? { - return ContextMenu.interactionView(in: tableView, with: configuration) + ContextMenu.interactionView(in: tableView, with: configuration) } - + @objc static func interactionView(inCollectionView collectionView: UICollectionView, with configuration: UIContextMenuConfiguration) -> UIView? { - return ContextMenu.interactionView(in: collectionView, with: configuration) + ContextMenu.interactionView(in: collectionView, with: configuration) } - + @objc static func commitPreview(in viewController: UIViewController, animator: UIContextMenuInteractionCommitAnimating) { ContextMenu.commitPreview(in: viewController, animator: animator) } diff --git a/Application/Sources/UI/Helpers/Environment.swift b/Application/Sources/UI/Helpers/Environment.swift index 4c656da0d..4643508e5 100644 --- a/Application/Sources/UI/Helpers/Environment.swift +++ b/Application/Sources/UI/Helpers/Environment.swift @@ -42,7 +42,7 @@ extension EnvironmentValues { self[EditingKey.self] = newValue } } - + /** * Selection state. */ @@ -54,7 +54,7 @@ extension EnvironmentValues { self[SelectedKey.self] = newValue } } - + /** * UIKit focus state (if focus set by UIKit). */ @@ -66,24 +66,24 @@ extension EnvironmentValues { self[UIKitFocusedKey.self] = newValue } } - + /** * UIKit size class support for iOS and tvOS (`UserInterfaceSizeClass` is marked as unavailable for tvOS, * unlike `UIUserInterfaceSizeClass`, leading to more preprocessor use than should be necessary). */ var uiHorizontalSizeClass: UIUserInterfaceSizeClass { -#if os(iOS) - return horizontalSizeClass == .compact ? .compact : .regular -#else - return .regular -#endif + #if os(iOS) + return horizontalSizeClass == .compact ? .compact : .regular + #else + return .regular + #endif } - + var uiVerticalSizeClass: UIUserInterfaceSizeClass { -#if os(iOS) - return verticalSizeClass == .compact ? .compact : .regular -#else - return .regular -#endif + #if os(iOS) + return verticalSizeClass == .compact ? .compact : .regular + #else + return .regular + #endif } } diff --git a/Application/Sources/UI/Helpers/MediaDescription.swift b/Application/Sources/UI/Helpers/MediaDescription.swift index fb3467852..9158b4e16 100644 --- a/Application/Sources/UI/Helpers/MediaDescription.swift +++ b/Application/Sources/UI/Helpers/MediaDescription.swift @@ -10,9 +10,9 @@ import SRGAppearanceSwift override init() { fatalError("init() is not available") } - + // MARK: - Title and subtitle - + enum Style { /// Show information emphasis case show @@ -21,39 +21,35 @@ import SRGAppearanceSwift /// Time information emphasis case time } - + static func title(for media: SRGMedia, style: Style) -> String? { switch style { case .show: if let show = media.show, areRedundant(media: media, show: show) { - return formattedDate(for: media) - } - else { - return media.title + formattedDate(for: media) + } else { + media.title } case .date, .time: - return media.title + media.title } } - + static func subtitle(for media: SRGMedia, style: Style) -> String? { guard media.contentType != .livestream else { return nil } - + switch style { case .show: if let show = media.show { if areRedundant(media: media, show: show) { return show.title - } - else if let formattedDate = formattedDate(for: media, style: .shortDate) { + } else if let formattedDate = formattedDate(for: media, style: .shortDate) { // Unbreakable spaces before / after the separator return "\(show.title) · \(formattedDate)" - } - else { + } else { return show.title } - } - else { + } else { return formattedDate(for: media) } case .date: @@ -62,88 +58,84 @@ import SRGAppearanceSwift return formattedTime(for: media) } } - + static func summary(for media: SRGMedia) -> String? { - return media.play_summary?.compacted + media.play_summary?.compacted } - + static func duration(for media: SRGMedia) -> Double? { - guard media.contentType != .livestream && media.contentType != .scheduledLivestream else { return nil } + guard media.contentType != .livestream, media.contentType != .scheduledLivestream else { return nil } return media.duration / 1000 } - + @objc static func availability(for media: SRGMedia?) -> String { guard let media else { return "" } - + var values: [String] = [] - + if let date = formattedDate(for: media, style: .shortDateAndTime) { values.append(date) } - + if let expirationDate = formattedExpirationDate(for: media) { values.append(expirationDate) } - + // Unbreakable spaces before / after the separator return values.joined(separator: " · ") } - + // MARK: - Accessibility - + static func cellAccessibilityLabel(for media: SRGMedia) -> String? { - let accessibilityLabel: String - if let show = media.show, !areRedundant(media: media, show: show) { - accessibilityLabel = show.title.appending(", \(media.title)") + let accessibilityLabel: String = if let show = media.show, !areRedundant(media: media, show: show) { + show.title.appending(", \(media.title)") + } else { + media.title } - else { - accessibilityLabel = media.title - } - + if let youthProtectionLabel = SRGAccessibilityLabelForYouthProtectionColor(media.youthProtectionColor) { return accessibilityLabel.appending(", \(youthProtectionLabel)") - } - else { + } else { return accessibilityLabel } } - + @objc static func availabilityAccessibilityLabel(for media: SRGMedia?) -> String? { guard let media else { return nil } - + var values: [String] = [] - + if let date = formattedDate(for: media, style: .shortDateAndTime, accessibilityLabel: true) { values.append(date) } - + if let expirationDate = formattedExpirationDate(for: media, accessibilityLabel: true) { values.append(expirationDate) } - + // Unbreakable spaces before / after the separator return values.joined(separator: " · ") } - + // MARK: - Badges - + struct BadgeProperties { let text: String let color: UIColor } - + static func liveBadgeProperties() -> BadgeProperties { - return BadgeProperties( + BadgeProperties( text: NSLocalizedString("Live", comment: "Short label identifying a livestream. Display in uppercase.").uppercased(), color: .srgLightRed ) } - + static func availabilityBadgeProperties(for media: SRGMedia) -> BadgeProperties? { if media.contentType == .livestream { return liveBadgeProperties() - } - else { + } else { let now = Date() let availability = media.timeAvailability(at: now) switch availability { @@ -161,21 +153,18 @@ import SRGAppearanceSwift case .available: if media.contentType == .scheduledLivestream { return liveBadgeProperties() - } - else if media.play_isWebFirst && ApplicationConfiguration.shared.isWebFirstBadgeEnabled { + } else if media.play_isWebFirst && ApplicationConfiguration.shared.isWebFirstBadgeEnabled { return BadgeProperties( text: NSLocalizedString("Web first", comment: "Short label identifying a web first content."), color: .srgDarkRed ) - } - else if let endDate = media.endDate, media.contentType == .episode || media.contentType == .clip, - let remainingTime = Self.formattedDuration(from: now, to: endDate) { + } else if let endDate = media.endDate, media.contentType == .episode || media.contentType == .clip, + let remainingTime = Self.formattedDuration(from: now, to: endDate) { return BadgeProperties( text: String(format: NSLocalizedString("%@ left", comment: "Short label displayed on a media expiring soon"), remainingTime), color: .play_orange ) - } - else { + } else { return nil } default: @@ -183,73 +172,77 @@ import SRGAppearanceSwift } } } - + // MARK: - Private methods - + private static func formattedDuration(from: Date, to: Date) -> String? { let components = Calendar.current.dateComponents([.day, .minute], from: from, to: to) switch components.day! { case 0: switch components.minute! { - case 0..<60: + case 0 ..< 60: return PlayFormattedMinutes(to.timeIntervalSince(from)) default: return PlayFormattedHours(to.timeIntervalSince(from)) } - case 1...3: + case 1 ... 3: return PlayFormattedDays(to.timeIntervalSince(from)) default: return nil } } - + private enum DateStyle { case date case shortDate case shortDateAndTime } - + private static func formattedDate(for media: SRGMedia, style: DateStyle = .date, accessibilityLabel: Bool = false) -> String? { if media.play_isWebFirst { - return NSLocalizedString("In advance", comment: "Short text replacing date for a web first content.") - } - else if shouldDisplayDate(for: media) { + NSLocalizedString("In advance", comment: "Short text replacing date for a web first content.") + } else if shouldDisplayDate(for: media) { switch style { case .date: - return accessibilityLabel - ? PlayAccessibilityRelativeDateFromDate(media.date) - : DateFormatter.play_relativeDate.string(from: media.date).capitalizedFirstLetter + if accessibilityLabel { + PlayAccessibilityRelativeDateFromDate(media.date) + } else { + DateFormatter.play_relativeDate.string(from: media.date).capitalizedFirstLetter + } case .shortDate: - return accessibilityLabel - ? PlayAccessibilityRelativeDateFromDate(media.date) - : DateFormatter.play_relativeShortDate.string(from: media.date) + if accessibilityLabel { + PlayAccessibilityRelativeDateFromDate(media.date) + } else { + DateFormatter.play_relativeShortDate.string(from: media.date) + } case .shortDateAndTime: - return accessibilityLabel - ? PlayAccessibilityDateAndTimeFromDate(media.date) - : DateFormatter.play_shortDateAndTime.string(from: media.date) + if accessibilityLabel { + PlayAccessibilityDateAndTimeFromDate(media.date) + } else { + DateFormatter.play_shortDateAndTime.string(from: media.date) + } } - } - else { - return nil + } else { + nil } } - + private static func formattedExpirationDate(for media: SRGMedia, accessibilityLabel: Bool = false) -> String? { guard let endDate = media.endDate, shouldDisplayExpirationDate(for: media) else { return nil } let dateString = accessibilityLabel - ? PlayAccessibilityDateFromDate(endDate) - : DateFormatter.play_shortDate.string(from: endDate) + ? PlayAccessibilityDateFromDate(endDate) + : DateFormatter.play_shortDate.string(from: endDate) return String(format: NSLocalizedString("Available until %@", comment: "Availability until date, specified as parameter"), dateString) } - + private static func formattedTime(for media: SRGMedia) -> String { - return DateFormatter.play_time.string(from: media.date) + DateFormatter.play_time.string(from: media.date) } - + private static func areRedundant(media: SRGMedia, show: SRGShow) -> Bool { - return media.title.lowercased() == show.title.lowercased() + media.title.lowercased() == show.title.lowercased() } - + private static func shouldDisplayExpirationDate(for media: SRGMedia) -> Bool { let now = Date() guard media.timeAvailability(at: now) == .available, @@ -258,11 +251,11 @@ import SRGAppearanceSwift let remainingDateComponents = Calendar.current.dateComponents([.day], from: now, to: endDate) return remainingDateComponents.day! > 3 } - + private static func shouldDisplayDate(for media: SRGMedia) -> Bool { let now = Date() return media.timeAvailability(at: now) != .notYetAvailable - && media.contentType != .livestream - && !(media.contentType == .scheduledLivestream && media.timeAvailability(at: now) == .available) + && media.contentType != .livestream + && !(media.contentType == .scheduledLivestream && media.timeAvailability(at: now) == .available) } } diff --git a/Application/Sources/UI/Helpers/RedactingView.swift b/Application/Sources/UI/Helpers/RedactingView.swift index 3efe85894..f64a168ca 100644 --- a/Application/Sources/UI/Helpers/RedactingView.swift +++ b/Application/Sources/UI/Helpers/RedactingView.swift @@ -11,14 +11,13 @@ import SwiftUI struct RedactingView: View { let content: Input let modifier: (Input) -> Output - + @Environment(\.redactionReasons) private var reasons - + var body: some View { if reasons.isEmpty { content - } - else { + } else { modifier(content) } } @@ -27,23 +26,23 @@ struct RedactingView: View { extension View { /// Call a modifier block when the receiver is redacted, allowing to further /// customize its behavior. - func whenRedacted(apply modifier: @escaping (Self) -> T) -> some View { - return RedactingView(content: self, modifier: modifier) + func whenRedacted(apply modifier: @escaping (Self) -> some View) -> some View { + RedactingView(content: self, modifier: modifier) } - + /// Make the receiver redactable (hiding its content, even redactable one, and replacing /// it with a redacted rectangle of the same size). func redactable() -> some View { - return whenRedacted { $0.hidden().background(Color(white: 1, opacity: 0.15)) } + whenRedacted { $0.hidden().background(Color(white: 1, opacity: 0.15)) } } - + /// Make the receiver unredactable (hidden when redacted). func unredactable() -> some View { - return whenRedacted { $0.hidden() } + whenRedacted { $0.hidden() } } - + /// Make the receiver redacted when the provided argument is `nil`. func redactedIfNil(_ object: Any?) -> some View { - return redacted(reason: object == nil ? .placeholder : .init()) + redacted(reason: object == nil ? .placeholder : .init()) } } diff --git a/Application/Sources/UI/Helpers/UICollectionView+Index.swift b/Application/Sources/UI/Helpers/UICollectionView+Index.swift index bf7593f3c..83a39abb6 100644 --- a/Application/Sources/UI/Helpers/UICollectionView+Index.swift +++ b/Application/Sources/UI/Helpers/UICollectionView+Index.swift @@ -19,71 +19,71 @@ protocol Indexable { */ class IndexedCollectionViewDiffableDataSource: UICollectionViewDiffableDataSource where Section: Hashable & Indexable, Item: Hashable { private let minimumIndexTitlesCount: Int - + init(collectionView: UICollectionView, minimumIndexTitlesCount: Int, cellProvider: @escaping CellProvider) { self.minimumIndexTitlesCount = max(minimumIndexTitlesCount, 2) super.init(collectionView: collectionView, cellProvider: cellProvider) } - + override convenience init(collectionView: UICollectionView, cellProvider: @escaping CellProvider) { self.init(collectionView: collectionView, minimumIndexTitlesCount: 2, cellProvider: cellProvider) } - - override func indexTitles(for collectionView: UICollectionView) -> [String]? { + + override func indexTitles(for _: UICollectionView) -> [String]? { let sectionIdentifiers = snapshot().sectionIdentifiers return (sectionIdentifiers.count >= minimumIndexTitlesCount) ? sectionIdentifiers.map(\.indexTitle) : nil } - - override func collectionView(_ collectionView: UICollectionView, indexPathForIndexTitle title: String, at index: Int) -> IndexPath { - return IndexPath(row: 0, section: index) + + override func collectionView(_: UICollectionView, indexPathForIndexTitle _: String, at index: Int) -> IndexPath { + IndexPath(row: 0, section: index) } } #if os(iOS) -/** - * Index titles have been made available for iOS 14+, but they suffer from two issues in comparison to the - * `UITableView` API: - * - Lack of proper reload API if the data is not known initially. - * - Lack of color customization API. - * This is a known limitation (see https://developer.apple.com/forums/thread/6565859 but fortunately it is - * easy to fill these gaps. - * - * No such API is required on tvOS, as the index title API is called on demand when scrolling fast. Colors - * also look fine. - */ -extension UICollectionView { - private func sectionIndexBar() -> UIView? { - return subviews.first { view in - return NSStringFromClass(type(of: view)).contains("I24n4d23ex5Bar7A6cc86ess98oryV6i6ew".unobfuscated()) + /** + * Index titles have been made available for iOS 14+, but they suffer from two issues in comparison to the + * `UITableView` API: + * - Lack of proper reload API if the data is not known initially. + * - Lack of color customization API. + * This is a known limitation (see https://developer.apple.com/forums/thread/6565859 but fortunately it is + * easy to fill these gaps. + * + * No such API is required on tvOS, as the index title API is called on demand when scrolling fast. Colors + * also look fine. + */ + extension UICollectionView { + private func sectionIndexBar() -> UIView? { + subviews.first { view in + NSStringFromClass(type(of: view)).contains("I24n4d23ex5Bar7A6cc86ess98oryV6i6ew".unobfuscated()) + } } - } - - private static func setColor(_ color: UIColor?, on view: UIView, selector: Selector) { - if view.responds(to: selector) { - view.perform(selector, with: color) + + private static func setColor(_ color: UIColor?, on view: UIView, selector: Selector) { + if view.responds(to: selector) { + view.perform(selector, with: color) + } } - } - - private static func setIndexColor(_ color: UIColor?, on view: UIView) { - setColor(color, on: view, selector: NSSelectorFromString("s6et72I3nde3xC3ol84o3r:9".unobfuscated())) - } - - private static func setIndexBackgroundColor(_ color: UIColor?, on view: UIView) { - setColor(color, on: view, selector: NSSelectorFromString("s3e4t5No6nTr57ac5775ki7ngB765a5ckg5ro89und09Col67or:76".unobfuscated())) - } - - func setSectionBarAppearance(indexColor: UIColor?, indexBackgroundColor: UIColor?) { - if let indexBar = sectionIndexBar() { - Self.setIndexColor(indexColor, on: indexBar) - Self.setIndexBackgroundColor(indexBackgroundColor, on: indexBar) + + private static func setIndexColor(_ color: UIColor?, on view: UIView) { + setColor(color, on: view, selector: NSSelectorFromString("s6et72I3nde3xC3ol84o3r:9".unobfuscated())) } - } - - func reloadSectionIndexBar() { - let selector = NSSelectorFromString("9_r5e44lo679ad92S3e4ct56i78on89Ind45e6x7T88i9tl4es".unobfuscated()) - if responds(to: selector) { - perform(selector) + + private static func setIndexBackgroundColor(_ color: UIColor?, on view: UIView) { + setColor(color, on: view, selector: NSSelectorFromString("s3e4t5No6nTr57ac5775ki7ngB765a5ckg5ro89und09Col67or:76".unobfuscated())) + } + + func setSectionBarAppearance(indexColor: UIColor?, indexBackgroundColor: UIColor?) { + if let indexBar = sectionIndexBar() { + Self.setIndexColor(indexColor, on: indexBar) + Self.setIndexBackgroundColor(indexBackgroundColor, on: indexBar) + } + } + + func reloadSectionIndexBar() { + let selector = NSSelectorFromString("9_r5e44lo679ad92S3e4ct56i78on89Ind45e6x7T88i9tl4es".unobfuscated()) + if responds(to: selector) { + perform(selector) + } } } -} #endif diff --git a/Application/Sources/UI/Views/AccessibilityView.swift b/Application/Sources/UI/Views/AccessibilityView.swift index 9dc1db27f..355ca3b3d 100644 --- a/Application/Sources/UI/Views/AccessibilityView.swift +++ b/Application/Sources/UI/Views/AccessibilityView.swift @@ -13,28 +13,28 @@ import UIKit @objc class AccessibilityView: UIView { @IBOutlet private weak var delegate: AccessibilityViewDelegate? - + public func setDelegate(_ delegate: AccessibilityViewDelegate?) { self.delegate = delegate } - + override var isAccessibilityElement: Bool { get { - return true + true } set {} } - + override var accessibilityLabel: String? { get { - return delegate?.labelForAccessibilityView(self) + delegate?.labelForAccessibilityView(self) } set {} } - + override var accessibilityHint: String? { get { - return delegate?.hintForAccessibilityView(self) + delegate?.hintForAccessibilityView(self) } set {} } diff --git a/Application/Sources/UI/Views/ActivityIndicator.swift b/Application/Sources/UI/Views/ActivityIndicator.swift index 5260fe5f9..af652323b 100644 --- a/Application/Sources/UI/Views/ActivityIndicator.swift +++ b/Application/Sources/UI/Views/ActivityIndicator.swift @@ -15,13 +15,13 @@ struct ActivityIndicator: View { LoadingImageView() .frame(width: 90, height: 90) } - + private struct LoadingImageView: UIViewRepresentable { - func makeUIView(context: Context) -> UIImageView { - return UIImageView.play_largeLoadingImageView(withTintColor: .srgGrayD2) + func makeUIView(context _: Context) -> UIImageView { + UIImageView.play_largeLoadingImageView(withTintColor: .srgGrayD2) } - - func updateUIView(_ uiView: UIImageView, context: Context) { + + func updateUIView(_: UIImageView, context _: Context) { // No update logic required } } diff --git a/Application/Sources/UI/Views/BadgeList.swift b/Application/Sources/UI/Views/BadgeList.swift index 0ec31a138..22af17361 100644 --- a/Application/Sources/UI/Views/BadgeList.swift +++ b/Application/Sources/UI/Views/BadgeList.swift @@ -10,11 +10,11 @@ import SwiftUI /// Behavior: h-exp, v-hug struct BadgeList: View { let data: Data - + init(data: Data) { self.data = data } - + static func data(for program: SRGProgram) -> Data? { let data = Data( hasSubtitles: program.subtitlesAvailable, @@ -25,7 +25,7 @@ struct BadgeList: View { ) return data.hasBadges ? data : nil } - + var body: some View { HStack(spacing: 6) { if data.hasSubtitles { @@ -58,13 +58,13 @@ extension BadgeList { let hasAudioDescription: Bool let hasSignLanguage: Bool let hasDolbyDigital: Bool - + var hasBadges: Bool { - return hasSubtitles - || hasMultiAudio - || hasAudioDescription - || hasSignLanguage - || hasDolbyDigital + hasSubtitles + || hasMultiAudio + || hasAudioDescription + || hasSignLanguage + || hasDolbyDigital } } } diff --git a/Application/Sources/UI/Views/Badges.swift b/Application/Sources/UI/Views/Badges.swift index 9375e6f8c..aab1cf8f3 100644 --- a/Application/Sources/UI/Views/Badges.swift +++ b/Application/Sources/UI/Views/Badges.swift @@ -17,13 +17,13 @@ struct Badge: View { let text: String let color: Color let textColor: Color - + init(text: String, color: Color, textColor: Color = .white) { self.text = text self.color = color self.textColor = textColor } - + var body: some View { Text(text) .srgFont(.label) @@ -41,7 +41,7 @@ struct Badge: View { /// Behavior: h-hug, v-hug struct DurationBadge: View { let duration: Double - + var body: some View { Text(PlayShortFormattedMinutes(duration)) .srgFont(.caption) @@ -122,7 +122,7 @@ struct ThreeSixtyBadge: View { /// Behhavior: h-hug, v-hug struct YouthProtectionBadge: View { let color: SRGYouthProtectionColor - + var body: some View { if let image = UIImage.image(for: color) { Image(uiImage: image) @@ -149,7 +149,7 @@ struct Badges_Previews: PreviewProvider { .padding() .background(Color.white) .previewLayout(.sizeThatFits) - + HStack { YouthProtectionBadge(color: .yellow) YouthProtectionBadge(color: .red) diff --git a/Application/Sources/UI/Views/Banner.swift b/Application/Sources/UI/Views/Banner.swift index 08efb2757..b843415fe 100644 --- a/Application/Sources/UI/Views/Banner.swift +++ b/Application/Sources/UI/Views/Banner.swift @@ -4,9 +4,9 @@ // License information is available from the LICENSE file. // -import UIKit import SRGAppearance import SRGDataProvider +import UIKit /** * Supported banner styles. @@ -33,11 +33,11 @@ import SRGDataProvider guard let message else { return } - + var accessibilityPrefix: String? var backgroundColor: UIColor? var foregroundColor: UIColor? - + switch style { case .info: accessibilityPrefix = PlaySRGAccessibilityLocalizedString("Information", comment: "Introductory title for information notifications") @@ -52,7 +52,7 @@ import SRGDataProvider backgroundColor = .srgRed foregroundColor = .white } - + SwiftMessagesBridge.show(message, accessibilityPrefix: accessibilityPrefix, image: image, @@ -60,7 +60,7 @@ import SRGDataProvider foregroundColor: foregroundColor, sticky: sticky) } - + /** * Hide all banners. */ @@ -80,7 +80,7 @@ extension Banner { return } var displayedError = error - + // Multiple errors. Pick the first if error.domain == SRGNetworkErrorDomain, error.code == SRGNetworkErrorCode.multiple.rawValue, @@ -88,15 +88,15 @@ extension Banner { let subError = subErrors.first { displayedError = subError } - + // Never display cancellation errors if displayedError.domain == NSURLErrorDomain, displayedError.code == NSURLErrorCancelled { return } - + show(with: .error, message: displayedError.localizedDescription, image: nil, sticky: false) } - + /** * Show a banner telling the user that the specified item has been added or removed from favorites. * @@ -107,15 +107,15 @@ extension Banner { if name == nil { name = NSLocalizedString("The selected content", comment: "Name of the favorite item, if no title or name to display") } - + let messageFormatString = isFavorite ? - NSLocalizedString("%@ has been added to favorites", comment: "Message displayed at the top of the screen when adding a show to favorites. Quotes are managed by the application.") : - NSLocalizedString("%@ has been deleted from favorites", comment: "Message displayed at the top of the screen when removing a show from favorites. Quotes are managed by the application.") + NSLocalizedString("%@ has been added to favorites", comment: "Message displayed at the top of the screen when adding a show to favorites. Quotes are managed by the application.") : + NSLocalizedString("%@ has been deleted from favorites", comment: "Message displayed at the top of the screen when removing a show from favorites. Quotes are managed by the application.") let message = String(format: messageFormatString, BannerShortenedName(name)) let image = UIImage(resource: isFavorite ? .favoriteFull : .favorite) show(with: .info, message: message, image: image, sticky: false) } - + /** * Show a banner telling the user that the specified item has been added or removed from downloads. * @@ -126,15 +126,15 @@ extension Banner { if name == nil { name = NSLocalizedString("The selected content", comment: "Name of the download item, if no title or name to display") } - + let messageFormatString = downloaded ? - NSLocalizedString("%@ has been added to downloads", comment: "Message displayed at the top of the screen when adding a media to downloads. Quotes are managed by the application.") : - NSLocalizedString("%@ has been deleted from downloads", comment: "Message displayed at the top of the screen when removing a media from downloads. Quotes are managed by the application.") + NSLocalizedString("%@ has been added to downloads", comment: "Message displayed at the top of the screen when adding a media to downloads. Quotes are managed by the application.") : + NSLocalizedString("%@ has been deleted from downloads", comment: "Message displayed at the top of the screen when removing a media from downloads. Quotes are managed by the application.") let message = String(format: messageFormatString, BannerShortenedName(name)) let image = UIImage(resource: downloaded ? .download : .downloadRemove) show(with: .info, message: message, image: image, sticky: false) } - + /** * Show a banner telling the user that the specified item has been added to or removed from the subscription list. * @@ -145,15 +145,15 @@ extension Banner { if name == nil { name = NSLocalizedString("The selected content", comment: "Name of the subscription item, if no title or name to display") } - + let messageFormatString = subscribed ? - NSLocalizedString("Notifications have been enabled for %@", comment: "Message displayed at the top of the screen when enabling push notifications. Quotes around the content placeholder managed by the application.") : - NSLocalizedString("Notifications have been disabled for %@", comment: "Message at the top of the screen displayed when disabling push notifications. Quotes around the content placeholder are managed by the application.") + NSLocalizedString("Notifications have been enabled for %@", comment: "Message displayed at the top of the screen when enabling push notifications. Quotes around the content placeholder managed by the application.") : + NSLocalizedString("Notifications have been disabled for %@", comment: "Message at the top of the screen displayed when disabling push notifications. Quotes around the content placeholder are managed by the application.") let message = String(format: messageFormatString, BannerShortenedName(name)) let image = UIImage(resource: subscribed ? .subscriptionFull : .subscription) show(with: .info, message: message, image: image, sticky: false) } - + /** * Show a banner telling the user that the specified item has been added to or removed from the later list. * @@ -164,15 +164,15 @@ extension Banner { if name == nil { name = NSLocalizedString("The selected content", comment: "Name of the later list item, if no title or name to display") } - + let messageFormatString = added ? - NSLocalizedString("%@ has been added to \"Later\"", comment: "Message displayed at the top of the screen when adding a media to the later list. Quotes around the content placeholder are managed by the application.") : - NSLocalizedString("%@ has been deleted from \"Later\"", comment: "Message displayed at the top of the screen when removing an item from the later list. Quotes around the content placeholder are managed by the application.") + NSLocalizedString("%@ has been added to \"Later\"", comment: "Message displayed at the top of the screen when adding a media to the later list. Quotes around the content placeholder are managed by the application.") : + NSLocalizedString("%@ has been deleted from \"Later\"", comment: "Message displayed at the top of the screen when removing an item from the later list. Quotes around the content placeholder are managed by the application.") let message = String(format: messageFormatString, BannerShortenedName(name)) let image = UIImage(resource: added ? .watchLaterFull : .watchLater) show(with: .info, message: message, image: image, sticky: false) } - + /** * Show a banner telling the user that the specified event has been added to calendar. * @@ -182,7 +182,7 @@ extension Banner { guard let title else { return } - + let messageFormatString = NSLocalizedString("%@ has been added to calendar", comment: "Message displayed at the top of the screen when adding a program to Calendar. Quotes are managed by the application.") let message = String(format: messageFormatString, BannerShortenedName(title)) let image = UIImage(resource: .calendar) @@ -194,7 +194,7 @@ private func BannerShortenedName(_ name: String?) -> String { guard let name else { return "" } - + let maxTitleLength = 60 if name.count > maxTitleLength { return "\"\(name.prefix(maxTitleLength))…\"" diff --git a/Application/Sources/UI/Views/BlockingOverlay.swift b/Application/Sources/UI/Views/BlockingOverlay.swift index 90df5e897..7fc47fc0d 100644 --- a/Application/Sources/UI/Views/BlockingOverlay.swift +++ b/Application/Sources/UI/Views/BlockingOverlay.swift @@ -12,16 +12,16 @@ import SwiftUI struct BlockingOverlay: View { let media: SRGMedia? let messageDisplayed: Bool - + init(media: SRGMedia?, messageDisplayed: Bool = false) { self.media = media self.messageDisplayed = messageDisplayed } - + private var blockingReason: SRGBlockingReason? { - return media?.blockingReason(at: Date()) + media?.blockingReason(at: Date()) } - + var body: some View { if let blockingReason, let blockingIconImage = UIImage.image(for: blockingReason) { ZStack { diff --git a/Application/Sources/UI/Views/Blur.swift b/Application/Sources/UI/Views/Blur.swift index cc67d319d..5744e17f1 100644 --- a/Application/Sources/UI/Views/Blur.swift +++ b/Application/Sources/UI/Views/Blur.swift @@ -15,12 +15,12 @@ import SwiftUI */ struct Blur: UIViewRepresentable { let style: UIBlurEffect.Style - - func makeUIView(context: Context) -> UIVisualEffectView { - return UIVisualEffectView(effect: UIBlurEffect(style: style)) + + func makeUIView(context _: Context) -> UIVisualEffectView { + UIVisualEffectView(effect: UIBlurEffect(style: style)) } - - func updateUIView(_ uiView: UIVisualEffectView, context: Context) { + + func updateUIView(_ uiView: UIVisualEffectView, context _: Context) { uiView.effect = UIBlurEffect(style: style) } } diff --git a/Application/Sources/UI/Views/CalendarView.swift b/Application/Sources/UI/Views/CalendarView.swift index 8e03a42dd..c4ef2f7ee 100644 --- a/Application/Sources/UI/Views/CalendarView.swift +++ b/Application/Sources/UI/Views/CalendarView.swift @@ -19,16 +19,16 @@ struct CalendarView: View { @ObservedObject var model: ProgramGuideViewModel @State private var selectedDate = Date() @FirstResponder private var firstResponder - + var body: some View { VStack { DatePicker("", selection: $selectedDate, displayedComponents: [.date]) .datePickerStyle(GraphicalDatePickerStyle()) .colorMultiply(.white) .accentColor(.red) - + Divider() - + ExpandingButton(label: NSLocalizedString("OK", comment: "Title of the button to validate date settings")) { firstResponder.sendAction(#selector(CalendarViewActions.close)) } diff --git a/Application/Sources/UI/Views/ChannelButton.swift b/Application/Sources/UI/Views/ChannelButton.swift index b1dd91e85..aee2ef6a4 100644 --- a/Application/Sources/UI/Views/ChannelButton.swift +++ b/Application/Sources/UI/Views/ChannelButton.swift @@ -14,13 +14,13 @@ import SwiftUI struct ChannelButton: View { let channel: SRGChannel? let action: () -> Void - + @Environment(\.isSelected) var isSelected - + private var imageUrl: URL? { - return url(for: channel?.rawImage, size: .small) + url(for: channel?.rawImage, size: .small) } - + var body: some View { Button(action: action) { if let imageUrl { @@ -28,13 +28,11 @@ struct ChannelButton: View { if let image = state.image { image .resizingMode(.aspectFit) - } - else { + } else { TitleView(channel: channel) } } - } - else { + } else { TitleView(channel: channel) } } @@ -47,10 +45,10 @@ struct ChannelButton: View { .cornerRadius(100) .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint, traits: .isButton) } - + private struct TitleView: View { let channel: SRGChannel? - + var body: some View { if let title = channel?.title { Text(title) @@ -65,11 +63,11 @@ struct ChannelButton: View { private extension ChannelButton { var accessibilityLabel: String? { - return channel?.title + channel?.title } - + var accessibilityHint: String? { - return PlaySRGAccessibilityLocalizedString("Shows the channel programs", comment: "Channel selector button hint") + PlaySRGAccessibilityLocalizedString("Shows the channel programs", comment: "Channel selector button hint") } } diff --git a/Application/Sources/UI/Views/DampedCollectionView.swift b/Application/Sources/UI/Views/DampedCollectionView.swift index 189b5e27d..5829738e2 100644 --- a/Application/Sources/UI/Views/DampedCollectionView.swift +++ b/Application/Sources/UI/Views/DampedCollectionView.swift @@ -16,40 +16,39 @@ import UIKit class DampedCollectionView: UICollectionView { override func didAddSubview(_ subview: UIView) { super.didAddSubview(subview) - + if let scrollView = subview as? UIScrollView { Self.applySettings(to: scrollView) } } - + static func applySettings(to scrollView: UIScrollView) { guard let scrollViewClass = object_getClass(scrollView) else { return } - + scrollView.decelerationRate = .fast scrollView.alwaysBounceHorizontal = true - + let scrollViewSubclassName = String(cString: class_getName(scrollViewClass)).appending("_IgnoreSafeArea") if let viewSubclass = NSClassFromString(scrollViewSubclassName) { object_setClass(scrollView, viewSubclass) - } - else { + } else { guard let viewClassNameUtf8 = (scrollViewSubclassName as NSString).utf8String else { return } guard let scrollViewSubclass = objc_allocateClassPair(scrollViewClass, viewClassNameUtf8, 0) else { return } - + if let decelerationRateMethod = class_getInstanceMethod(UIScrollView.self, #selector(setter: UIScrollView.decelerationRate)) { let setDecelerationRate: @convention(block) (AnyObject, UIScrollView.DecelerationRate) -> Void = { _, _ in // Do nothing, only prevent value changes } class_addMethod(scrollViewSubclass, #selector(setter: UIScrollView.decelerationRate), imp_implementationWithBlock(setDecelerationRate), method_getTypeEncoding(decelerationRateMethod)) } - + if let alwaysBounceHorizontalMethod = class_getInstanceMethod(UIScrollView.self, #selector(setter: UIScrollView.alwaysBounceHorizontal)) { let setAlwaysBounceHorizontal: @convention(block) (AnyObject, Bool) -> Void = { _, _ in // Do nothing, only prevent value changes } class_addMethod(scrollViewSubclass, #selector(setter: UIScrollView.alwaysBounceHorizontal), imp_implementationWithBlock(setAlwaysBounceHorizontal), method_getTypeEncoding(alwaysBounceHorizontalMethod)) } - + objc_registerClassPair(scrollViewSubclass) object_setClass(scrollView, scrollViewSubclass) } diff --git a/Application/Sources/UI/Views/DiskInfoFooterView.swift b/Application/Sources/UI/Views/DiskInfoFooterView.swift index 8cc3cdf87..954b09a56 100644 --- a/Application/Sources/UI/Views/DiskInfoFooterView.swift +++ b/Application/Sources/UI/Views/DiskInfoFooterView.swift @@ -11,7 +11,7 @@ import SwiftUI struct DiskInfoFooterView: View { @StateObject private var model = DiskInfoFooterViewModel() - + var body: some View { Text(model.formattedFreeSpace) .srgFont(.caption) diff --git a/Application/Sources/UI/Views/DiskInfoFooterViewModel.swift b/Application/Sources/UI/Views/DiskInfoFooterViewModel.swift index c943a2d8e..09729a83b 100644 --- a/Application/Sources/UI/Views/DiskInfoFooterViewModel.swift +++ b/Application/Sources/UI/Views/DiskInfoFooterViewModel.swift @@ -10,12 +10,12 @@ import Combine final class DiskInfoFooterViewModel: ObservableObject { @Published private var freeByteCount: Int64 = 0 - + var formattedFreeSpace: String { let formattedByteCount = ByteCountFormatter.string(fromByteCount: freeByteCount, countStyle: .file) return String(format: NSLocalizedString("Free space: %@", comment: "Total free space size displayed as a list footer"), formattedByteCount) } - + init() { Timer.publish(every: 10, on: .main, in: .common) .autoconnect() diff --git a/Application/Sources/UI/Views/DownloadCell.swift b/Application/Sources/UI/Views/DownloadCell.swift index 56191cb3b..14c356c62 100644 --- a/Application/Sources/UI/Views/DownloadCell.swift +++ b/Application/Sources/UI/Views/DownloadCell.swift @@ -15,53 +15,51 @@ struct DownloadCell: View { case horizontal case adaptive } - + @Binding private(set) var download: Download? @StateObject private var model = DownloadCellViewModel() - + let layout: Layout - + @Environment(\.isEditing) private var isEditing @Environment(\.isSelected) private var isSelected @Environment(\.uiHorizontalSizeClass) private var horizontalSizeClass - + private var direction: StackDirection { if layout == .horizontal || (layout == .adaptive && horizontalSizeClass == .compact) { - return .horizontal - } - else { - return .vertical + .horizontal + } else { + .vertical } } - + private var horizontalPadding: CGFloat { - return direction == .vertical ? 0 : 10 + direction == .vertical ? 0 : 10 } - + private var verticalPadding: CGFloat { - return direction == .vertical ? 5 : 0 + direction == .vertical ? 5 : 0 } - + private var hasSelectionAppearance: Bool { - return isSelected && download != nil + isSelected && download != nil } - + private var imageUrl: URL? { - return url(for: download?.image, size: .small) + url(for: download?.image, size: .small) } - + init(download: Download?, layout: Layout = .adaptive) { _download = .constant(download) self.layout = layout } - + var body: some View { Stack(direction: direction, spacing: 0) { Group { if let media = download?.media { MediaVisualView(media: media, size: .small, embeddedDirection: direction) - } - else { + } else { ImageView(source: imageUrl) } } @@ -71,7 +69,7 @@ struct DownloadCell: View { .cornerRadius(LayoutStandardViewCornerRadius) .redactable() .layoutPriority(1) - + DescriptionView(model: model, embeddedDirection: direction) .selectionAppearance(.transluscent, when: hasSelectionAppearance, while: isEditing) .padding(.leading, horizontalPadding) @@ -85,17 +83,17 @@ struct DownloadCell: View { model.download = newValue } } - + /// Behavior: h-exp, v-exp private struct DescriptionView: View { @ObservedObject var model: DownloadCellViewModel - + let embeddedDirection: StackDirection - + private var title: String { - return model.title ?? .placeholder(length: 10) + model.title ?? .placeholder(length: 10) } - + var body: some View { VStack(alignment: .leading, spacing: 0) { if embeddedDirection == .horizontal, let properties = model.availabilityBadgeProperties { @@ -119,11 +117,11 @@ struct DownloadCell: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } - + /// Behavior: h-hug, v-hug private struct Icon: View { @ObservedObject var model: DownloadCellViewModel - + var body: some View { switch model.state { case .added, .suspended, .unknown: @@ -137,20 +135,20 @@ struct DownloadCell: View { } } } - + /// Behavior: h-hug, v-hug private struct AnimatedDownloadIcon: View { var body: some View { DownloadImageView() .frame(width: 16, height: 16) } - + private struct DownloadImageView: UIViewRepresentable { - func makeUIView(context: Context) -> UIImageView { - return UIImageView.play_smallDownloadingImageView(withTintColor: .srgGrayD2) + func makeUIView(context _: Context) -> UIImageView { + UIImageView.play_smallDownloadingImageView(withTintColor: .srgGrayD2) } - - func updateUIView(_ uiView: UIImageView, context: Context) { + + func updateUIView(_: UIImageView, context _: Context) { // No update logic required } } @@ -164,18 +162,17 @@ private extension DownloadCell { guard let download else { return nil } if let media = download.media { return MediaDescription.cellAccessibilityLabel(for: media) - } - else { + } else { return download.title } } - + var accessibilityHint: String? { - return !isEditing ? PlaySRGAccessibilityLocalizedString("Plays the content.", comment: "Download cell hint") : PlaySRGAccessibilityLocalizedString("Toggles selection.", comment: "Download cell hint in edit mode") + !isEditing ? PlaySRGAccessibilityLocalizedString("Plays the content.", comment: "Download cell hint") : PlaySRGAccessibilityLocalizedString("Toggles selection.", comment: "Download cell hint in edit mode") } - + var accessibilityTraits: AccessibilityTraits { - return isSelected ? .isSelected : [] + isSelected ? .isSelected : [] } } @@ -183,16 +180,16 @@ private extension DownloadCell { enum DownloadCellSize { fileprivate static let aspectRatio: CGFloat = 16 / 9 - + private static let defaultItemWidth: CGFloat = 210 private static let heightOffset: CGFloat = 70 - + static func grid(layoutWidth: CGFloat, spacing: CGFloat) -> NSCollectionLayoutSize { - return LayoutGridCellSize(defaultItemWidth, aspectRatio, heightOffset, layoutWidth, spacing, 1) + LayoutGridCellSize(defaultItemWidth, aspectRatio, heightOffset, layoutWidth, spacing, 1) } - + static func fullWidth() -> NSCollectionLayoutSize { - return NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(84)) + NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(84)) } } @@ -201,7 +198,7 @@ enum DownloadCellSize { struct DownloadCell_Previews: PreviewProvider { private static let verticalLayoutSize = DownloadCellSize.grid(layoutWidth: 1024, spacing: 16).previewSize private static let horizontalLayoutSize = DownloadCellSize.fullWidth().previewSize - + static var previews: some View { Group { DownloadCell(download: Mock.download(), layout: .vertical) @@ -211,7 +208,7 @@ struct DownloadCell_Previews: PreviewProvider { DownloadCell(download: Mock.download(.nineSixteen), layout: .vertical) } .previewLayout(.fixed(width: verticalLayoutSize.width, height: verticalLayoutSize.height)) - + Group { DownloadCell(download: Mock.download(), layout: .horizontal) DownloadCell(download: Mock.download(.noShow), layout: .horizontal) diff --git a/Application/Sources/UI/Views/DownloadCellViewModel.swift b/Application/Sources/UI/Views/DownloadCellViewModel.swift index f13fff8aa..3d8100c5f 100644 --- a/Application/Sources/UI/Views/DownloadCellViewModel.swift +++ b/Application/Sources/UI/Views/DownloadCellViewModel.swift @@ -11,16 +11,16 @@ import Combine final class DownloadCellViewModel: ObservableObject { @Published var download: Download? @Published private(set) var state: State = .unknown - + var availabilityBadgeProperties: MediaDescription.BadgeProperties? { guard let media = download?.media else { return nil } return MediaDescription.availabilityBadgeProperties(for: media) } - + var title: String? { - return download?.title + download?.title } - + var subtitle: String? { switch state { case let .downloading(progress: progress): @@ -30,11 +30,11 @@ final class DownloadCellViewModel: ObservableObject { return ByteCountFormatter.string(fromByteCount: size, countStyle: .file) } } - + init() { $download .map { download in - return Publishers.Merge( + Publishers.Merge( NotificationCenter.default.weakPublisher(for: NSNotification.Name.DownloadStateDidChange, object: download) .compactMap { $0.userInfo?[DownloadStateKey] as? Int } .map { Self.state(from: DownloadState(rawValue: $0), for: download) }, @@ -64,17 +64,16 @@ extension DownloadCellViewModel { case suspended case downloaded } - + private static func progress(for download: Download?) -> Progress { if let download, let progress = Download.currentlyKnownProgress(for: download) { - return progress - } - else { + progress + } else { // Display 0% if nothing - return Progress(totalUnitCount: 10) + Progress(totalUnitCount: 10) } } - + private static func state(from downloadState: DownloadState?, for download: Download?) -> State { guard let downloadState else { return .unknown } switch downloadState { diff --git a/Application/Sources/UI/Views/EmptyContentView.swift b/Application/Sources/UI/Views/EmptyContentView.swift index b7d110639..43aa4d266 100644 --- a/Application/Sources/UI/Views/EmptyContentView.swift +++ b/Application/Sources/UI/Views/EmptyContentView.swift @@ -13,18 +13,18 @@ struct EmptyContentView: View { case standard case text } - + let state: State let layout: Layout let insets: EdgeInsets - + init(state: State, layout: Layout = .standard, insets: EdgeInsets = .init(top: 0, leading: 0, bottom: 0, trailing: 0)) { self.state = state self.layout = layout self.insets = insets } - - private func largeImage(for type: `Type`) -> ImageResource { + + private func largeImage(for type: Type) -> ImageResource { switch type { case .episodesFromFavorites, .favoriteShows: return .favoriteBackground @@ -36,16 +36,16 @@ struct EmptyContentView: View { return .searchBackground case .watchLater: return .watchLaterBackground -#if os(iOS) - case .notifications: - return .subscriptionBackground - case .downloads: - return .downloadBackground -#endif + #if os(iOS) + case .notifications: + return .subscriptionBackground + case .downloads: + return .downloadBackground + #endif } } - - private func emptyTitle(for type: `Type`) -> String { + + private func emptyTitle(for type: Type) -> String { switch type { case .favoriteShows: return NSLocalizedString("No favorites", comment: "Text displayed when no favorites are available") @@ -55,17 +55,17 @@ struct EmptyContentView: View { return NSLocalizedString("No results", comment: "Default text displayed when no results are available") case .searchTutorial: return NSLocalizedString("Type to start searching", comment: "Message displayed when there is no search criterium entered") -#if os(iOS) - case .notifications: - return NSLocalizedString("No notifications", comment: "Text displayed when no notifications are available") - case .downloads: - return NSLocalizedString("No downloads", comment: "Text displayed when no downloads are available") -#endif + #if os(iOS) + case .notifications: + return NSLocalizedString("No notifications", comment: "Text displayed when no notifications are available") + case .downloads: + return NSLocalizedString("No downloads", comment: "Text displayed when no downloads are available") + #endif default: return NSLocalizedString("No content", comment: "Default text displayed when no content is available") } } - + var body: some View { Group { switch state { @@ -100,10 +100,10 @@ struct EmptyContentView: View { extension EmptyContentView { enum State { case loading - case empty(type: `Type`) + case empty(type: Type) case failed(error: Error) } - + enum `Type`: Hashable { case episodesFromFavorites case favoriteShows @@ -113,10 +113,10 @@ extension EmptyContentView { case search case searchTutorial case watchLater -#if os(iOS) - case notifications - case downloads -#endif + #if os(iOS) + case notifications + case downloads + #endif } } @@ -125,15 +125,15 @@ extension EmptyContentView { struct EmptyContentView_Previews: PreviewProvider { enum PreviewError: LocalizedError { case kernel32 - + var errorDescription: String? { switch self { case .kernel32: - return "Error loading kernel32.dll. The specified module could not be found." + "Error loading kernel32.dll. The specified module could not be found." } } } - + static var previews: some View { Group { Group { @@ -141,9 +141,9 @@ struct EmptyContentView_Previews: PreviewProvider { EmptyContentView(state: .empty(type: .favoriteShows)) EmptyContentView(state: .empty(type: .generic)) EmptyContentView(state: .empty(type: .history)) -#if os(iOS) - EmptyContentView(state: .empty(type: .downloads)) -#endif + #if os(iOS) + EmptyContentView(state: .empty(type: .downloads)) + #endif } Group { EmptyContentView(state: .empty(type: .resumePlayback)) diff --git a/Application/Sources/UI/Views/ExpandingButton.swift b/Application/Sources/UI/Views/ExpandingButton.swift index 2b54f0472..bbd0b9ac1 100644 --- a/Application/Sources/UI/Views/ExpandingButton.swift +++ b/Application/Sources/UI/Views/ExpandingButton.swift @@ -16,12 +16,12 @@ struct ExpandingButton: View, PrimaryColorSettable, PrimaryFocusedColorSettable private let accessibilityLabel: String private let accessibilityHint: String? private let action: () -> Void - - internal var primaryColor: Color = .srgGrayD2 - internal var primaryFocusedColor: Color = .srgGray16 - + + var primaryColor: Color = .srgGrayD2 + var primaryFocusedColor: Color = .srgGray16 + @State private var isFocused = false - + init(icon: ImageResource, label: String, accessibilityLabel: String? = nil, accessibilityHint: String? = nil, action: @escaping () -> Void) { self.icon = icon self.label = label @@ -29,23 +29,23 @@ struct ExpandingButton: View, PrimaryColorSettable, PrimaryFocusedColorSettable self.accessibilityHint = accessibilityHint self.action = action } - + init(label: String, accessibilityLabel: String? = nil, accessibilityHint: String? = nil, action: @escaping () -> Void) { - self.icon = nil + icon = nil self.label = label self.accessibilityLabel = accessibilityLabel ?? label self.accessibilityHint = accessibilityHint self.action = action } - + init(icon: ImageResource, accessibilityLabel: String? = nil, accessibilityHint: String? = nil, action: @escaping () -> Void) { self.icon = icon - self.label = nil + label = nil self.accessibilityLabel = accessibilityLabel ?? "" self.accessibilityHint = accessibilityHint self.action = action } - + var body: some View { Button(action: action) { HStack(spacing: 8) { diff --git a/Application/Sources/UI/Views/FeaturedContentCell.swift b/Application/Sources/UI/Views/FeaturedContentCell.swift index 11c487c09..56ff7975c 100644 --- a/Application/Sources/UI/Views/FeaturedContentCell.swift +++ b/Application/Sources/UI/Views/FeaturedContentCell.swift @@ -14,54 +14,69 @@ struct FeaturedContentCell: View, PrimaryColorSettable case headline case element } - + enum Style { /// Show information emphasis case show /// Date information emphasis case date } - + let content: Content let layout: Layout let style: Style - - internal var primaryColor: Color = .srgGrayD2 - internal var secondaryColor: Color = .srgGray96 - + + var primaryColor: Color = .srgGrayD2 + var secondaryColor: Color = .srgGray96 + @Environment(\.isSelected) private var isSelected @Environment(\.uiHorizontalSizeClass) private var horizontalSizeClass - + private var direction: StackDirection { - return (horizontalSizeClass == .compact) ? .vertical : .horizontal + (horizontalSizeClass == .compact) ? .vertical : .horizontal } - + private var horizontalPadding: CGFloat { - return (horizontalSizeClass == .compact) ? 8 : constant(iOS: 54, tvOS: 50) + (horizontalSizeClass == .compact) ? 8 : constant(iOS: 54, tvOS: 50) } - + private var verticalPadding: CGFloat { - return (horizontalSizeClass == .compact) ? 12 : constant(iOS: 16, tvOS: 16) + (horizontalSizeClass == .compact) ? 12 : constant(iOS: 16, tvOS: 16) } - + private var descriptionAlignment: FeaturedDescriptionView.Alignment { if direction == .vertical { - return .topLeading - } - else { - return layout == .headline ? .center : .leading + .topLeading + } else { + layout == .headline ? .center : .leading } } - + private var detailed: Bool { - return horizontalSizeClass == .regular + horizontalSizeClass == .regular } - + var body: some View { Group { -#if os(tvOS) - ExpandingCardButton(action: content.action) { - HStack(spacing: 0) { + #if os(tvOS) + ExpandingCardButton(action: content.action) { + HStack(spacing: 0) { + content.visualView() + .aspectRatio(FeaturedContentCellSize.aspectRatio, contentMode: .fit) + .layoutPriority(1) + FeaturedDescriptionView(content: content, alignment: descriptionAlignment, detailed: detailed) + .primaryColor(primaryColor) + .secondaryColor(secondaryColor) + .padding(.horizontal, horizontalPadding) + .padding(.vertical, verticalPadding) + } + .background(Color.srgGray23) + .cornerRadius(LayoutStandardViewCornerRadius) + .unredactable() + .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint, traits: .isButton) + } + #else + Stack(direction: direction, spacing: 0) { content.visualView() .aspectRatio(FeaturedContentCellSize.aspectRatio, contentMode: .fit) .layoutPriority(1) @@ -72,27 +87,11 @@ struct FeaturedContentCell: View, PrimaryColorSettable .padding(.vertical, verticalPadding) } .background(Color.srgGray23) + .redactable() + .selectionAppearance(when: isSelected && !content.isPlaceholder) .cornerRadius(LayoutStandardViewCornerRadius) - .unredactable() - .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint, traits: .isButton) - } -#else - Stack(direction: direction, spacing: 0) { - content.visualView() - .aspectRatio(FeaturedContentCellSize.aspectRatio, contentMode: .fit) - .layoutPriority(1) - FeaturedDescriptionView(content: content, alignment: descriptionAlignment, detailed: detailed) - .primaryColor(primaryColor) - .secondaryColor(secondaryColor) - .padding(.horizontal, horizontalPadding) - .padding(.vertical, verticalPadding) - } - .background(Color.srgGray23) - .redactable() - .selectionAppearance(when: isSelected && !content.isPlaceholder) - .cornerRadius(LayoutStandardViewCornerRadius) - .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint) -#endif + .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint) + #endif } .redacted(reason: content.isPlaceholder ? .placeholder : .init()) } @@ -116,11 +115,11 @@ extension FeaturedContentCell where Content == FeaturedShowContent { private extension FeaturedContentCell { var accessibilityLabel: String? { - return content.accessibilityLabel + content.accessibilityLabel } - + var accessibilityHint: String? { - return content.accessibilityHint + content.accessibilityHint } } @@ -128,22 +127,20 @@ private extension FeaturedContentCell { enum FeaturedContentCellSize { fileprivate static let aspectRatio: CGFloat = 16 / 9 - + static func headline(layoutWidth: CGFloat, horizontalSizeClass: UIUserInterfaceSizeClass) -> NSCollectionLayoutSize { if horizontalSizeClass == .compact { - return LayoutSwimlaneCellSize(layoutWidth, aspectRatio, 100) - } - else { - return LayoutFractionedCellSize(layoutWidth, aspectRatio, 0.6) + LayoutSwimlaneCellSize(layoutWidth, aspectRatio, 100) + } else { + LayoutFractionedCellSize(layoutWidth, aspectRatio, 0.6) } } - + static func element(layoutWidth: CGFloat, horizontalSizeClass: UIUserInterfaceSizeClass) -> NSCollectionLayoutSize { if horizontalSizeClass == .compact { - return LayoutSwimlaneCellSize(layoutWidth, aspectRatio, 80) - } - else { - return LayoutFractionedCellSize(layoutWidth, aspectRatio, 0.4) + LayoutSwimlaneCellSize(layoutWidth, aspectRatio, 80) + } else { + LayoutFractionedCellSize(layoutWidth, aspectRatio, 0.4) } } } @@ -152,14 +149,11 @@ enum FeaturedContentCellSize { private extension View { func previewLayout(for layout: FeaturedContentCell.Layout, layoutWidth: CGFloat, horizontalSizeClass: UIUserInterfaceSizeClass) -> some View { - let size: CGSize = { - if layout == .headline { - return FeaturedContentCellSize.headline(layoutWidth: layoutWidth, horizontalSizeClass: horizontalSizeClass).previewSize - } - else { - return FeaturedContentCellSize.element(layoutWidth: layoutWidth, horizontalSizeClass: horizontalSizeClass).previewSize - } - }() + let size: CGSize = if layout == .headline { + FeaturedContentCellSize.headline(layoutWidth: layoutWidth, horizontalSizeClass: horizontalSizeClass).previewSize + } else { + FeaturedContentCellSize.element(layoutWidth: layoutWidth, horizontalSizeClass: horizontalSizeClass).previewSize + } return previewLayout(.fixed(width: size.width, height: size.height)) .horizontalSizeClass(horizontalSizeClass) } @@ -167,31 +161,35 @@ private extension View { struct FeaturedContentCell_Previews: PreviewProvider { private static let kind: Mock.Media = .standard - private static let label = "New" - + private static let label = "New label with long text, quite long" + static var previews: some View { -#if os(tvOS) - FeaturedContentCell(media: Mock.media(kind), style: .show, label: label, layout: .headline) - .previewLayout(for: .headline, layoutWidth: 1800, horizontalSizeClass: .regular) - - FeaturedContentCell(media: Mock.media(kind), style: .show, label: label, layout: .element) - .previewLayout(for: .headline, layoutWidth: 1800, horizontalSizeClass: .regular) -#else - FeaturedContentCell(media: Mock.media(kind), style: .show, label: label, layout: .headline) - .previewLayout(for: .headline, layoutWidth: 1200, horizontalSizeClass: .regular) - .environment(\.horizontalSizeClass, .regular) - - FeaturedContentCell(media: Mock.media(kind), style: .show, label: label, layout: .headline) - .previewLayout(for: .headline, layoutWidth: 800, horizontalSizeClass: .compact) - .environment(\.horizontalSizeClass, .compact) - - FeaturedContentCell(media: Mock.media(kind), style: .show, label: label, layout: .element) - .previewLayout(for: .element, layoutWidth: 1200, horizontalSizeClass: .regular) - .environment(\.horizontalSizeClass, .regular) - - FeaturedContentCell(media: Mock.media(kind), style: .show, label: label, layout: .element) - .previewLayout(for: .element, layoutWidth: 800, horizontalSizeClass: .compact) - .environment(\.horizontalSizeClass, .compact) + #if os(tvOS) + FeaturedContentCell(media: Mock.media(kind), style: .show, label: label, layout: .headline) + .previewLayout(for: .headline, layoutWidth: 1800, horizontalSizeClass: .regular) + + FeaturedContentCell(media: Mock.media(kind), style: .show, label: label, layout: .element) + .previewLayout(for: .headline, layoutWidth: 1800, horizontalSizeClass: .regular) + #else + FeaturedContentCell(media: Mock.media(kind), style: .show, label: label, layout: .headline) + .previewLayout(for: .headline, layoutWidth: 1200, horizontalSizeClass: .regular) + .environment(\.horizontalSizeClass, .regular) + + FeaturedContentCell(media: Mock.media(kind), style: .show, label: label, layout: .headline) + .previewLayout(for: .headline, layoutWidth: 800, horizontalSizeClass: .compact) + .environment(\.horizontalSizeClass, .compact) + + FeaturedContentCell(media: Mock.media(kind), style: .show, label: label, layout: .element) + .previewLayout(for: .element, layoutWidth: 1200, horizontalSizeClass: .regular) + .environment(\.horizontalSizeClass, .regular) + + FeaturedContentCell(media: Mock.media(kind), style: .show, label: label, layout: .element) + .previewLayout(for: .element, layoutWidth: 800, horizontalSizeClass: .compact) + .environment(\.horizontalSizeClass, .compact) + + FeaturedContentCell(media: Mock.media(kind), style: .show, label: label, layout: .element) + .previewLayout(for: .element, layoutWidth: 320, horizontalSizeClass: .compact) + .environment(\.horizontalSizeClass, .compact) #endif } } diff --git a/Application/Sources/UI/Views/FeaturedDescriptionView.swift b/Application/Sources/UI/Views/FeaturedDescriptionView.swift index 4a86b7353..db6c5b142 100644 --- a/Application/Sources/UI/Views/FeaturedDescriptionView.swift +++ b/Application/Sources/UI/Views/FeaturedDescriptionView.swift @@ -15,38 +15,39 @@ struct FeaturedDescriptionView: View, PrimaryColorSett case topLeading case center } - + let content: Content let alignment: Alignment let detailed: Bool - - internal var primaryColor: Color = .srgGrayD2 - internal var secondaryColor: Color = .srgGray96 - + + var primaryColor: Color = .srgGrayD2 + var secondaryColor: Color = .srgGray96 + private var stackAlignment: HorizontalAlignment { - return alignment == .center ? .center : .leading + alignment == .center ? .center : .leading } - + private var frameAlignment: SwiftUI.Alignment { switch alignment { case .leading: - return .leading + .leading case .topLeading: - return .topLeading + .topLeading case .center: - return .center + .center } } - + private var textAlignment: TextAlignment { - return alignment == .center ? .center : .leading + alignment == .center ? .center : .leading } - + var body: some View { VStack(alignment: stackAlignment, spacing: 6) { HStack(spacing: constant(iOS: 8, tvOS: 12)) { if let label = content.label { Badge(text: label, color: Color(.srgDarkRed)) + .layoutPriority(1) } if let introduction = content.introduction { Text(introduction) @@ -55,7 +56,7 @@ struct FeaturedDescriptionView: View, PrimaryColorSett .foregroundColor(secondaryColor) } } - + VStack(alignment: stackAlignment, spacing: 10) { Text(content.title ?? "") .srgFont(.H3) @@ -91,8 +92,8 @@ extension FeaturedDescriptionView where Content == FeaturedShowContent { // MARK: Preview struct FeaturedDescriptionView_Previews: PreviewProvider { - private static let label = "New" - + private static let label = "New label with long text, quite long" + static var previews: some View { Group { FeaturedDescriptionView(show: Mock.show(), label: label, alignment: .leading, detailed: true) @@ -100,12 +101,19 @@ struct FeaturedDescriptionView_Previews: PreviewProvider { FeaturedDescriptionView(show: Mock.show(), label: label, alignment: .center, detailed: true) } .previewLayout(.fixed(width: 800, height: 300)) - + Group { FeaturedDescriptionView(media: Mock.media(), style: .show, label: label, alignment: .leading, detailed: true) FeaturedDescriptionView(media: Mock.media(), style: .show, label: label, alignment: .topLeading, detailed: true) FeaturedDescriptionView(media: Mock.media(), style: .show, label: label, alignment: .center, detailed: true) } .previewLayout(.fixed(width: 800, height: 300)) + + Group { + FeaturedDescriptionView(media: Mock.media(), style: .show, label: label, alignment: .leading, detailed: true) + FeaturedDescriptionView(media: Mock.media(), style: .show, label: label, alignment: .topLeading, detailed: true) + FeaturedDescriptionView(media: Mock.media(), style: .show, label: label, alignment: .center, detailed: true) + } + .previewLayout(.fixed(width: 300, height: 300)) } } diff --git a/Application/Sources/UI/Views/FocusTracker.swift b/Application/Sources/UI/Views/FocusTracker.swift index 6d5ecaaa5..868d907ff 100644 --- a/Application/Sources/UI/Views/FocusTracker.swift +++ b/Application/Sources/UI/Views/FocusTracker.swift @@ -11,16 +11,16 @@ import SwiftUI private struct FocusTracker: View { private let action: (Bool) -> Void @Binding private var content: () -> Content - + @Environment(\.isFocused) private var isFocused - + init(action: @escaping (Bool) -> Void, @ViewBuilder content: @escaping () -> Content) { self.action = action _content = .constant(content) } - + var body: some View { - self.content() + content() .onChange(of: isFocused) { action($0) } } } diff --git a/Application/Sources/UI/Views/FocusableRegion.swift b/Application/Sources/UI/Views/FocusableRegion.swift index 8d7a925f7..2f18c1400 100644 --- a/Application/Sources/UI/Views/FocusableRegion.swift +++ b/Application/Sources/UI/Views/FocusableRegion.swift @@ -13,37 +13,37 @@ import SwiftUI */ private struct FocusableRegion: UIViewRepresentable { @Binding private var content: () -> Content - + init(@ViewBuilder content: @escaping () -> Content) { _content = .constant(content) } - + func makeCoordinator() -> UIHostingController { - return UIHostingController(rootView: content(), ignoreSafeArea: true) + UIHostingController(rootView: content(), ignoreSafeArea: true) } - + func makeUIView(context: Context) -> UIView { let hostView = context.coordinator.view! hostView.backgroundColor = .clear - + let focusGuide = UIFocusGuide() focusGuide.preferredFocusEnvironments = [WeakFocusEnvironment(hostView)] hostView.addLayoutGuide(focusGuide) - + NSLayoutConstraint.activate([ focusGuide.topAnchor.constraint(equalTo: hostView.topAnchor), focusGuide.bottomAnchor.constraint(equalTo: hostView.bottomAnchor), focusGuide.leadingAnchor.constraint(equalTo: hostView.leadingAnchor), focusGuide.trailingAnchor.constraint(equalTo: hostView.trailingAnchor) ]) - + return hostView } - + func updateUIView(_ uiView: UIView, context: Context) { let hostController = context.coordinator hostController.rootView = content() - + // Make layout neutral uiView.applySizingBehavior(of: hostController) } @@ -55,35 +55,35 @@ extension FocusableRegion { */ class WeakFocusEnvironment: NSObject, UIFocusEnvironment { weak var wrappedEnvironment: UIFocusEnvironment? - + init(_ wrappedEnvironment: UIFocusEnvironment) { self.wrappedEnvironment = wrappedEnvironment } - + var preferredFocusEnvironments: [UIFocusEnvironment] { - return wrappedEnvironment?.preferredFocusEnvironments ?? [] + wrappedEnvironment?.preferredFocusEnvironments ?? [] } - + var parentFocusEnvironment: UIFocusEnvironment? { - return wrappedEnvironment?.parentFocusEnvironment + wrappedEnvironment?.parentFocusEnvironment } - + var focusItemContainer: UIFocusItemContainer? { - return wrappedEnvironment?.focusItemContainer + wrappedEnvironment?.focusItemContainer } - + func setNeedsFocusUpdate() { wrappedEnvironment?.setNeedsFocusUpdate() } - + func updateFocusIfNeeded() { wrappedEnvironment?.updateFocusIfNeeded() } - + func shouldUpdateFocus(in context: UIFocusUpdateContext) -> Bool { - return wrappedEnvironment?.shouldUpdateFocus(in: context) ?? false + wrappedEnvironment?.shouldUpdateFocus(in: context) ?? false } - + func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) { wrappedEnvironment?.didUpdateFocus(in: context, with: coordinator) } @@ -97,20 +97,19 @@ extension View { */ func focusable() -> some View { // Focus environments are available on iOS but not so useful. Do not wrap into a FocusableRegion unnecessarily. -#if os(tvOS) - Group { - if #available(tvOS 15, *) { - focusSection() - } - else { - FocusableRegion { - self + #if os(tvOS) + Group { + if #available(tvOS 15, *) { + focusSection() + } else { + FocusableRegion { + self + } } } - } -#else - return self -#endif + #else + return self + #endif } } diff --git a/Application/Sources/UI/Views/GoogleCastBarButtonItem.swift b/Application/Sources/UI/Views/GoogleCastBarButtonItem.swift index 132d5cda5..a7645a203 100644 --- a/Application/Sources/UI/Views/GoogleCastBarButtonItem.swift +++ b/Application/Sources/UI/Views/GoogleCastBarButtonItem.swift @@ -4,36 +4,36 @@ // License information is available from the LICENSE file. // -import UIKit import GoogleCast +import UIKit @objc class GoogleCastBarButtonItem: UIBarButtonItem { private var castButton: GCKUICastButton! private weak var navigationBar: UINavigationBar? private var tintColorObservation: NSKeyValueObservation? - + // MARK: - Object lifecycle - + @objc init(for navigationBar: UINavigationBar) { super.init() self.navigationBar = navigationBar - - self.castButton = GCKUICastButton(frame: CGRect(x: 0.0, y: 0.0, width: 44.0, height: 44.0)) - self.customView = castButton - + + castButton = GCKUICastButton(frame: CGRect(x: 0.0, y: 0.0, width: 44.0, height: 44.0)) + customView = castButton + tintColorObservation = navigationBar.observe(\.tintColor, options: [.new]) { [weak self] _, _ in self?.updateAppearance() } - + updateAppearance() } - + required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) } - + // MARK: - Updates - + private func updateAppearance() { castButton.tintColor = navigationBar?.tintColor } diff --git a/Application/Sources/UI/Views/GoogleCastFloatingButton.swift b/Application/Sources/UI/Views/GoogleCastFloatingButton.swift index 92eb15280..f4e5fcbb2 100644 --- a/Application/Sources/UI/Views/GoogleCastFloatingButton.swift +++ b/Application/Sources/UI/Views/GoogleCastFloatingButton.swift @@ -13,29 +13,29 @@ import SRGAppearanceSwift final class GoogleCastFloatingButton: GCKUICastButton { private static let side: CGFloat = 44 private static let margin: CGFloat = 2 - + override init(frame: CGRect) { super.init(frame: frame) layout() } - + required init(coder decoder: NSCoder) { super.init(coder: decoder) layout() } - + private func layout() { tintColor = .white - + translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ widthAnchor.constraint(equalToConstant: Self.side), heightAnchor.constraint(equalToConstant: Self.side) ]) - + let backgroundSide = Self.side - 2 * Self.margin assert(backgroundSide > 0, "Cast button layout parameters are incorrect") - + let backgroundLayer = CALayer() backgroundLayer.frame = CGRect(x: Self.margin, y: Self.margin, width: backgroundSide, height: backgroundSide) backgroundLayer.backgroundColor = UIColor.srgGray23.cgColor diff --git a/Application/Sources/UI/Views/GradientView.swift b/Application/Sources/UI/Views/GradientView.swift index 7c01b439e..bbfdab7d4 100644 --- a/Application/Sources/UI/Views/GradientView.swift +++ b/Application/Sources/UI/Views/GradientView.swift @@ -8,36 +8,36 @@ import UIKit @objc class GradientView: UIView { private var gradientLayer: CAGradientLayer! - + override init(frame: CGRect) { super.init(frame: frame) commonInit() } - + required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) commonInit() } - + override func layoutSubviews() { super.layoutSubviews() - + CATransaction.begin() CATransaction.setDisableActions(true) gradientLayer.frame = bounds CATransaction.commit() } - + @objc func updateWithStartColor(_ startColor: UIColor?, at startPoint: CGPoint, endColor: UIColor?, at endPoint: CGPoint, animated: Bool) { let update: () -> Void = { let fromColor = startColor ?? self.backgroundColor ?? .clear let toColor = endColor ?? self.backgroundColor ?? .clear - + self.gradientLayer.colors = [fromColor.cgColor, toColor.cgColor] self.gradientLayer.startPoint = startPoint self.gradientLayer.endPoint = endPoint } - + if animated { update() } else { @@ -47,7 +47,7 @@ import UIKit CATransaction.commit() } } - + private func commonInit() { gradientLayer = CAGradientLayer() layer.insertSublayer(gradientLayer, at: 0) diff --git a/Application/Sources/UI/Views/Handle.swift b/Application/Sources/UI/Views/Handle.swift index 408c340df..bda77c44b 100644 --- a/Application/Sources/UI/Views/Handle.swift +++ b/Application/Sources/UI/Views/Handle.swift @@ -8,12 +8,12 @@ import SwiftUI /// Behavior: h-exp, v-hug struct Handle: View { - let action: (() -> Void) - + let action: () -> Void + var body: some View { Button { action() - } label: { + } label: { // Use similar values as Aiolos `ResizeHandle`. GeometryReader { geometry in ZStack { @@ -26,12 +26,12 @@ struct Handle: View { .accessibilityElement(label: PlaySRGAccessibilityLocalizedString("Close", comment: "Close button label on handle view"), traits: .isButton) } - + /// Behavior: h-hug, v-hug private struct Grabber: View { private let grabberHeight = 5.0 private let grabberWidth = 38.0 - + var body: some View { RoundedRectangle(cornerRadius: grabberHeight / 2) .frame(width: grabberWidth, height: grabberHeight) diff --git a/Application/Sources/UI/Views/HeaderView.swift b/Application/Sources/UI/Views/HeaderView.swift index 0e1ada922..6c120ef1b 100644 --- a/Application/Sources/UI/Views/HeaderView.swift +++ b/Application/Sources/UI/Views/HeaderView.swift @@ -15,26 +15,25 @@ struct HeaderView: View { let subtitle: String? let hasDetailDisclosure: Bool let primaryColor: Color - + @Environment(\.uiHorizontalSizeClass) private var horizontalSizeClass @Environment(\.sizeCategory) private var sizeCategory - + init(title: String, subtitle: String?, hasDetailDisclosure: Bool, primaryColor: Color = .srgGrayD2) { self.title = title self.subtitle = subtitle self.hasDetailDisclosure = hasDetailDisclosure self.primaryColor = primaryColor } - + private var displayableSubtitle: String? { if horizontalSizeClass == .regular, let subtitle, !subtitle.isEmpty { - return subtitle - } - else { - return nil + subtitle + } else { + nil } } - + var body: some View { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 0) { @@ -70,8 +69,7 @@ enum HeaderViewSize { let hostController = UIHostingController(rootView: HeaderView(title: title, subtitle: subtitle, hasDetailDisclosure: true)) let size = hostController.sizeThatFits(in: CGSize(width: layoutWidth, height: UIView.layoutFittingExpandedSize.height)) return NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(size.height)) - } - else { + } else { return NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(LayoutHeaderHeightZero)) } } diff --git a/Application/Sources/UI/Views/HeroMediaCell.swift b/Application/Sources/UI/Views/HeroMediaCell.swift index 6ff869e95..043ce25c8 100644 --- a/Application/Sources/UI/Views/HeroMediaCell.swift +++ b/Application/Sources/UI/Views/HeroMediaCell.swift @@ -11,59 +11,57 @@ import SwiftUI struct HeroMediaCell: View { let media: SRGMedia? let label: String? - + @Environment(\.isSelected) private var isSelected - + var body: some View { -#if os(tvOS) - ExpandingCardButton(action: action) { + #if os(tvOS) + ExpandingCardButton(action: action) { + MainView(media: media, label: label) + .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint, traits: .isButton) + } + #else MainView(media: media, label: label) - .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint, traits: .isButton) - } -#else - MainView(media: media, label: label) - .cornerRadius(LayoutStandardViewCornerRadius) - .selectionAppearance(when: isSelected) - .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint) -#endif + .cornerRadius(LayoutStandardViewCornerRadius) + .selectionAppearance(when: isSelected) + .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint) + #endif } - -#if os(tvOS) - private func action() { - if let media { - navigateToMedia(media) + + #if os(tvOS) + private func action() { + if let media { + navigateToMedia(media) + } } - } -#endif - + #endif + private struct MainView: View { let media: SRGMedia? let label: String? - + @Environment(\.uiHorizontalSizeClass) private var horizontalSizeClass - + private var regularWidthContentMode: ImageView.ContentMode { - return .aspectFillFocused(relativeWidth: 0.5, relativeHeight: 0.55) + .aspectFillFocused(relativeWidth: 0.5, relativeHeight: 0.55) } - + private var contentMode: ImageView.ContentMode { if let focalPoint = media?.imageFocalPoint { return .aspectFillFocused(relativeWidth: focalPoint.relativeWidth, relativeHeight: focalPoint.relativeHeight) - } - else { -#if os(tvOS) - return regularWidthContentMode -#else - if horizontalSizeClass == .compact { - return .aspectFillTop - } - else { + } else { + #if os(tvOS) return regularWidthContentMode - } -#endif + #else + if horizontalSizeClass == .compact { + return .aspectFillTop + } else { + return regularWidthContentMode + } + #endif } } - + var body: some View { ZStack { MediaVisualView(media: media, size: .large, contentMode: contentMode) { media in @@ -76,22 +74,22 @@ struct HeroMediaCell: View { } } } - + /// Behavior: h-exp, v-exp private struct DescriptionView: View { let media: SRGMedia? let label: String? - + private var subtitle: String? { guard let media else { return nil } return MediaDescription.subtitle(for: media, style: .show) } - + private var title: String? { guard let media else { return nil } return MediaDescription.title(for: media, style: .show) } - + var body: some View { VStack { HStack(spacing: constant(iOS: 8, tvOS: 12)) { @@ -126,9 +124,9 @@ private extension HeroMediaCell { guard let media else { return nil } return MediaDescription.cellAccessibilityLabel(for: media) } - + var accessibilityHint: String? { - return PlaySRGAccessibilityLocalizedString("Plays the content.", comment: "Media cell hint") + PlaySRGAccessibilityLocalizedString("Plays the content.", comment: "Media cell hint") } } @@ -136,23 +134,21 @@ private extension HeroMediaCell { enum HeroMediaCellSize { static func recommended(layoutWidth: CGFloat, horizontalSizeClass: UIUserInterfaceSizeClass) -> NSCollectionLayoutSize { -#if os(tvOS) - let height: CGFloat = 700 -#else - let height = layoutWidth * aspectRatio(horizontalSizeClass: horizontalSizeClass) -#endif + #if os(tvOS) + let height: CGFloat = 700 + #else + let height = layoutWidth * aspectRatio(horizontalSizeClass: horizontalSizeClass) + #endif return NSCollectionLayoutSize(widthDimension: .absolute(layoutWidth), heightDimension: .absolute(height)) } - + private static func aspectRatio(horizontalSizeClass: UIUserInterfaceSizeClass) -> CGFloat { if horizontalSizeClass == .compact { - return 9 / 11 - } - else if let isLandscape = UIApplication.shared.mainWindow?.isLandscape, isLandscape { - return 2 / 5 - } - else { - return 1 / 2 + 9 / 11 + } else if let isLandscape = UIApplication.shared.mainWindow?.isLandscape, isLandscape { + 2 / 5 + } else { + 1 / 2 } } } @@ -169,14 +165,14 @@ private extension View { struct HeroMediaCell_Previews: PreviewProvider { static var previews: some View { -#if os(tvOS) - HeroMediaCell(media: Mock.media(), label: "New") - .previewLayout(forLayoutWidth: 1920, horizontalSizeClass: .regular) -#else - HeroMediaCell(media: Mock.media(), label: "New") - .previewLayout(forLayoutWidth: 375, horizontalSizeClass: .compact) - HeroMediaCell(media: Mock.media(), label: "New") - .previewLayout(forLayoutWidth: 800, horizontalSizeClass: .regular) -#endif + #if os(tvOS) + HeroMediaCell(media: Mock.media(), label: "New") + .previewLayout(forLayoutWidth: 1920, horizontalSizeClass: .regular) + #else + HeroMediaCell(media: Mock.media(), label: "New") + .previewLayout(forLayoutWidth: 375, horizontalSizeClass: .compact) + HeroMediaCell(media: Mock.media(), label: "New") + .previewLayout(forLayoutWidth: 800, horizontalSizeClass: .regular) + #endif } } diff --git a/Application/Sources/UI/Views/HighlightCell.swift b/Application/Sources/UI/Views/HighlightCell.swift index d21a34ad9..be60a93b7 100644 --- a/Application/Sources/UI/Views/HighlightCell.swift +++ b/Application/Sources/UI/Views/HighlightCell.swift @@ -13,64 +13,64 @@ struct HighlightCell: View { let section: Content.Section let item: Content.Item? let filter: SectionFiltering? - + @Environment(\.isSelected) private var isSelected - + var body: some View { -#if os(tvOS) - ExpandingCardButton(action: action) { + #if os(tvOS) + ExpandingCardButton(action: action) { + MainView(highlight: highlight) + .redactable() + .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint, traits: .isButton) + } + #else MainView(highlight: highlight) + .selectionAppearance(when: isSelected && highlight != nil) + .cornerRadius(LayoutStandardViewCornerRadius) .redactable() - .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint, traits: .isButton) - } -#else - MainView(highlight: highlight) - .selectionAppearance(when: isSelected && highlight != nil) - .cornerRadius(LayoutStandardViewCornerRadius) - .redactable() - .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint) -#endif + .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint) + #endif } - -#if os(tvOS) - private func action() { - if case let .show(show) = item { - navigateToShow(show) - } - else { - navigateToSection(section, filter: filter) + + #if os(tvOS) + private func action() { + if case let .show(show) = item { + navigateToShow(show) + } else if case let .media(media) = item { + navigateToMedia(media) + } else { + navigateToSection(section, filter: filter) + } } - } -#endif - + #endif + /// Behavior: h-exp, v-exp private struct MainView: View { let highlight: Highlight? - + @Environment(\.uiHorizontalSizeClass) private var horizontalSizeClass - + private var direction: StackDirection { - return (horizontalSizeClass == .compact) ? .vertical : .horizontal + (horizontalSizeClass == .compact) ? .vertical : .horizontal } - + private var isCompact: Bool { - return horizontalSizeClass == .compact + horizontalSizeClass == .compact } - + private var imageUrl: URL? { guard let highlight else { return nil } return url(for: highlight.image, size: .large) } - + private var contentMode: ImageView.ContentMode { if let focalPoint = highlight?.imageFocalPoint { - return .aspectFillFocused(relativeWidth: focalPoint.relativeWidth, relativeHeight: focalPoint.relativeHeight) - } - else { - return .aspectFillRight + .aspectFillFocused(relativeWidth: focalPoint.relativeWidth, relativeHeight: focalPoint.relativeHeight) + } else { + .aspectFillRight } } - + var body: some View { GeometryReader { geometry in if isCompact { @@ -86,8 +86,7 @@ struct HighlightCell: View { .frame(maxWidth: .infinity, alignment: .leading) } } - } - else { + } else { ZStack(alignment: .leading) { ImageView(source: imageUrl, contentMode: contentMode) if let highlight { @@ -102,11 +101,11 @@ struct HighlightCell: View { } } } - + /// Behavior: h-exp, v-hug private struct DescriptionView: View { let highlight: Highlight - + var body: some View { VStack(alignment: .leading, spacing: 15) { Text(highlight.title) @@ -129,11 +128,11 @@ struct HighlightCell: View { private extension HighlightCell { var accessibilityLabel: String? { - return highlight?.title + highlight?.title } - + var accessibilityHint: String? { - return PlaySRGAccessibilityLocalizedString("Opens details.", comment: "Highlight cell hint") + PlaySRGAccessibilityLocalizedString("Opens details.", comment: "Highlight cell hint") } } @@ -141,11 +140,11 @@ private extension HighlightCell { enum HighlightCellSize { private static func aspectRatio(horizontalSizeClass: UIUserInterfaceSizeClass) -> CGFloat { - return horizontalSizeClass == .compact ? 16 / 9 : 4 + horizontalSizeClass == .compact ? 16 / 9 : 4 } - + static func fullWidth(layoutWidth: CGFloat, horizontalSizeClass: UIUserInterfaceSizeClass) -> NSCollectionLayoutSize { - return LayoutSwimlaneCellSize(layoutWidth, aspectRatio(horizontalSizeClass: horizontalSizeClass), 0) + LayoutSwimlaneCellSize(layoutWidth, aspectRatio(horizontalSizeClass: horizontalSizeClass), 0) } } @@ -161,13 +160,13 @@ private extension View { struct HighlightCell_Previews: PreviewProvider { static let highlight = Mock.highlight() - + static var previews: some View { HighlightCell(highlight: highlight, section: .configured(.tvAllShows), item: nil, filter: nil) .previewLayout(layoutWidth: 1000, horizontalSizeClass: .regular) -#if os(iOS) - HighlightCell(highlight: highlight, section: .configured(.tvAllShows), item: nil, filter: nil) - .previewLayout(layoutWidth: 400, horizontalSizeClass: .compact) -#endif + #if os(iOS) + HighlightCell(highlight: highlight, section: .configured(.tvAllShows), item: nil, filter: nil) + .previewLayout(layoutWidth: 400, horizontalSizeClass: .compact) + #endif } } diff --git a/Application/Sources/UI/Views/HostViews.swift b/Application/Sources/UI/Views/HostViews.swift index 4ac374277..05bbae58d 100644 --- a/Application/Sources/UI/Views/HostViews.swift +++ b/Application/Sources/UI/Views/HostViews.swift @@ -15,15 +15,15 @@ struct HostCellView: View { let isSelected: Bool let isUIKitFocused: Bool @Binding private var content: Content - + init(editing: Bool, selected: Bool, UIKitFocused: Bool, content: Content) { isEditing = editing isSelected = selected isUIKitFocused = UIKitFocused - + _content = .constant(content) } - + var body: some View { content .environment(\.isEditing, isEditing) @@ -37,21 +37,20 @@ struct HostCellView: View { */ class HostCollectionViewCell: UICollectionViewCell { private(set) var hostController: UIHostingController>? - + private func update(with content: Content?, editing: Bool, selected: Bool, UIKitFocused: Bool) { if let content { let rootView = HostCellView(editing: editing, selected: selected, UIKitFocused: UIKitFocused, content: content) if let hostController { hostController.rootView = rootView - } - else { + } else { hostController = UIHostingController(rootView: rootView, ignoreSafeArea: true) } - + if let hostView = hostController?.view, hostView.superview != contentView { hostView.backgroundColor = .clear contentView.addSubview(hostView) - + hostView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ hostView.topAnchor.constraint(equalTo: contentView.topAnchor), @@ -60,29 +59,28 @@ class HostCollectionViewCell: UICollectionViewCell { hostView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor) ]) } - } - else if let hostView = hostController?.view { + } else if let hostView = hostController?.view { hostView.removeFromSuperview() } } - + var content: Content? { didSet { update(with: content, editing: isEditing, selected: isSelected, UIKitFocused: isUIKitFocused) } } - + private var isEditing: Bool { guard let collectionView = superview as? UICollectionView else { return false } return collectionView.isEditing } - + override var isSelected: Bool { didSet { update(with: content, editing: isEditing, selected: isSelected, UIKitFocused: isUIKitFocused) } } - + var isUIKitFocused = false { didSet { update(with: content, editing: isEditing, selected: isSelected, UIKitFocused: isUIKitFocused) @@ -95,20 +93,19 @@ class HostCollectionViewCell: UICollectionViewCell { */ class HostSupplementaryView: UICollectionReusableView { private(set) var hostController: UIHostingController? - + private func update(with content: Content?) { if let rootView = content { if let hostController { hostController.rootView = rootView - } - else { + } else { hostController = UIHostingController(rootView: rootView, ignoreSafeArea: true) } - + if let hostView = hostController?.view, hostView.superview != self { hostView.backgroundColor = .clear addSubview(hostView) - + hostView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ hostView.topAnchor.constraint(equalTo: topAnchor), @@ -117,12 +114,11 @@ class HostSupplementaryView: UICollectionReusableView { hostView.trailingAnchor.constraint(equalTo: trailingAnchor) ]) } - } - else if let hostView = hostController?.view { + } else if let hostView = hostController?.view { hostView.removeFromSuperview() } } - + var content: Content? { didSet { update(with: content) @@ -135,36 +131,36 @@ class HostSupplementaryView: UICollectionReusableView { */ class HostTableViewCell: UITableViewCell { private var hostController: UIHostingController>? - + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) - + tintColor = .red backgroundColor = .clear - + let selectedBackgroundView = UIView() selectedBackgroundView.backgroundColor = .clear self.selectedBackgroundView = selectedBackgroundView } - - required init?(coder: NSCoder) { + + @available(*, unavailable) + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } - + private func update(with content: Content?, editing: Bool, selected: Bool, selectionStyle: UITableViewCell.SelectionStyle, UIKitFocused: Bool) { if let content { let rootView = HostCellView(editing: editing, selected: selected && selectionStyle != .none, UIKitFocused: UIKitFocused, content: content) if let hostController { hostController.rootView = rootView - } - else { + } else { hostController = UIHostingController(rootView: rootView, ignoreSafeArea: true) } - + if let hostView = hostController?.view, hostView.superview != contentView { hostView.backgroundColor = .clear contentView.addSubview(hostView) - + hostView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ hostView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: LayoutMargin), @@ -173,41 +169,40 @@ class HostTableViewCell: UITableViewCell { hostView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -LayoutMargin * 2) ]) } - } - else if let hostView = hostController?.view { + } else if let hostView = hostController?.view { hostView.removeFromSuperview() } } - + var content: Content? { didSet { update(with: content, editing: isEditing, selected: isSelected, selectionStyle: selectionStyle, UIKitFocused: isUIKitFocused) } } - + override var isEditing: Bool { didSet { update(with: content, editing: isEditing, selected: isSelected, selectionStyle: selectionStyle, UIKitFocused: isUIKitFocused) } } - + override func setSelected(_ selected: Bool, animated: Bool) { super.setSelected(selected, animated: animated) update(with: content, editing: isEditing, selected: isSelected, selectionStyle: selectionStyle, UIKitFocused: isUIKitFocused) } - + override var isSelected: Bool { didSet { update(with: content, editing: isEditing, selected: isSelected, selectionStyle: selectionStyle, UIKitFocused: isUIKitFocused) } } - + override var selectionStyle: UITableViewCell.SelectionStyle { didSet { update(with: content, editing: isEditing, selected: isSelected, selectionStyle: selectionStyle, UIKitFocused: isUIKitFocused) } } - + var isUIKitFocused = false { didSet { update(with: content, editing: isEditing, selected: isSelected, selectionStyle: selectionStyle, UIKitFocused: isUIKitFocused) @@ -220,20 +215,19 @@ class HostTableViewCell: UITableViewCell { */ class HostTableViewHeaderFooterView: UITableViewHeaderFooterView { private(set) var hostController: UIHostingController? - + private func update(with content: Content?) { if let rootView = content { if let hostController { hostController.rootView = rootView - } - else { + } else { hostController = UIHostingController(rootView: rootView, ignoreSafeArea: true) } - + if let hostView = hostController?.view, hostView.superview != self { hostView.backgroundColor = .clear addSubview(hostView) - + hostView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ hostView.topAnchor.constraint(equalTo: topAnchor), @@ -242,12 +236,11 @@ class HostTableViewHeaderFooterView: UITableViewHeaderFooterView hostView.trailingAnchor.constraint(equalTo: trailingAnchor) ]) } - } - else if let hostView = hostController?.view { + } else if let hostView = hostController?.view { hostView.removeFromSuperview() } } - + var content: Content? { didSet { update(with: content) @@ -264,9 +257,9 @@ class HostView: UIView { let bottomAnchorConstant: CGFloat let leadingAnchorConstant: CGFloat let trailingAnchorConstant: CGFloat - + private var hostController: UIHostingController? - + init(frame: CGRect, ignoresSafeArea: Bool = true, topAnchorConstant: CGFloat = 0, bottomAnchorConstant: CGFloat = 0, leadingAnchorConstant: CGFloat = 0, trailingAnchorConstant: CGFloat = 0) { self.ignoresSafeArea = ignoresSafeArea self.topAnchorConstant = topAnchorConstant @@ -275,28 +268,28 @@ class HostView: UIView { self.trailingAnchorConstant = trailingAnchorConstant super.init(frame: frame) } - + override convenience init(frame: CGRect) { self.init(frame: frame, ignoresSafeArea: true) } - - required init?(coder: NSCoder) { + + @available(*, unavailable) + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } - + private func update(with content: Content?) { if let rootView = content { if let hostController { hostController.rootView = rootView - } - else { + } else { hostController = UIHostingController(rootView: rootView, ignoreSafeArea: ignoresSafeArea) } - + if let hostView = hostController?.view, hostView.superview != self { hostView.backgroundColor = .clear addSubview(hostView) - + hostView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ hostView.topAnchor.constraint(equalTo: topAnchor, constant: topAnchorConstant), @@ -305,12 +298,11 @@ class HostView: UIView { hostView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: trailingAnchorConstant) ]) } - } - else if let hostView = hostController?.view { + } else if let hostView = hostController?.view { hostView.removeFromSuperview() } } - + var content: Content? { didSet { update(with: content) diff --git a/Application/Sources/UI/Views/ImageView.swift b/Application/Sources/UI/Views/ImageView.swift index 988f49b33..05208a50c 100644 --- a/Application/Sources/UI/Views/ImageView.swift +++ b/Application/Sources/UI/Views/ImageView.swift @@ -51,69 +51,67 @@ struct ImageView: View { case aspectFillBottomRight case aspectFillFocused(relativeWidth: CGFloat, relativeHeight: CGFloat) } - + let source: ImageRequestConvertible? let contentMode: ContentMode - + private static func alignment(for contentMode: Self.ContentMode) -> Alignment { switch contentMode { case .aspectFit, .aspectFill, .center, .fill, .aspectFillFocused: - return .center + .center case .top, .aspectFitTop, .aspectFillTop: - return .top + .top case .bottom, .aspectFitBottom, .aspectFillBottom: - return .bottom + .bottom case .left, .aspectFitLeft, .aspectFillLeft: - return .leading + .leading case .right, .aspectFitRight, .aspectFillRight: - return .trailing + .trailing case .topLeft, .aspectFitTopLeft, .aspectFillTopLeft: - return .topLeading + .topLeading case .topRight, .aspectFitTopRight, .aspectFillTopRight: - return .topTrailing + .topTrailing case .bottomLeft, .aspectFitBottomLeft, .aspectFillBottomLeft: - return .bottomLeading + .bottomLeading case .bottomRight, .aspectFitBottomRight, .aspectFillBottomRight: - return .bottomTrailing + .bottomTrailing } } - + private static func fitSize(for imageContainer: ImageContainer, in geometry: GeometryProxy) -> CGSize { let imageSize = imageContainer.image.size - guard imageSize.width != 0 && imageSize.height != 0 else { return .zero } - + guard imageSize.width != 0, imageSize.height != 0 else { return .zero } + let targetSize = geometry.size - guard targetSize.width != 0 && targetSize.height != 0 else { return .zero } - + guard targetSize.width != 0, targetSize.height != 0 else { return .zero } + let imageAspectRatio = imageSize.width / imageSize.height let targetAspectRatio = targetSize.width / targetSize.height - + if targetAspectRatio > imageAspectRatio { return CGSize(width: targetSize.height * imageAspectRatio, height: targetSize.height) - } - else { + } else { return CGSize(width: targetSize.width, height: targetSize.width / imageAspectRatio) } } - + private static func fillSize(for imageContainer: ImageContainer, in geometry: GeometryProxy) -> CGSize { let imageSize = imageContainer.image.size - guard imageSize.width != 0 && imageSize.height != 0 else { return .zero } - + guard imageSize.width != 0, imageSize.height != 0 else { return .zero } + let targetSize = geometry.size - guard targetSize.width != 0 && targetSize.height != 0 else { return .zero } - + guard targetSize.width != 0, targetSize.height != 0 else { return .zero } + let imageAspectRatio = imageSize.width / imageSize.height let targetAspectRatio = targetSize.width / targetSize.height - + if targetAspectRatio > imageAspectRatio { return CGSize(width: targetSize.width, height: targetSize.width / imageAspectRatio) - } - else { + } else { return CGSize(width: targetSize.height * imageAspectRatio, height: targetSize.height) } } - + /** * Calculate the offset to apply so that the focal point P approaches the center C of the target frame as close as * possible while ensuring the resized filling image entirely covers the target frame. @@ -149,16 +147,16 @@ struct ImageView: View { height: (fillSize.height - targetSize.height) / 2 ) return CGSize( - width: -(focalPoint.x - fillSize.width / 2).clamped(to: -margins.width...margins.width), - height: (focalPoint.y - fillSize.height / 2).clamped(to: -margins.height...margins.height) + width: -(focalPoint.x - fillSize.width / 2).clamped(to: -margins.width ... margins.width), + height: (focalPoint.y - fillSize.height / 2).clamped(to: -margins.height ... margins.height) ) } - + init(source: ImageRequestConvertible?, contentMode: ContentMode = .aspectFit) { self.source = source self.contentMode = contentMode } - + var body: some View { GeometryReader { geometry in LazyImage(source: source) { state in @@ -177,18 +175,18 @@ struct ImageView: View { image .resizingMode(.fill) case .top, .bottom, .left, .right, - .topLeft, .topRight, .bottomLeft, .bottomRight: + .topLeft, .topRight, .bottomLeft, .bottomRight: image .frame(size: imageContainer.image.size) .frame(size: geometry.size, alignment: Self.alignment(for: contentMode)) case .aspectFitTop, .aspectFitBottom, .aspectFitLeft, .aspectFitRight, - .aspectFitTopLeft, .aspectFitTopRight, .aspectFitBottomLeft, .aspectFitBottomRight: + .aspectFitTopLeft, .aspectFitTopRight, .aspectFitBottomLeft, .aspectFitBottomRight: image .resizingMode(.fill) .frame(size: Self.fitSize(for: imageContainer, in: geometry)) .frame(size: geometry.size, alignment: Self.alignment(for: contentMode)) case .aspectFillTop, .aspectFillBottom, .aspectFillLeft, .aspectFillRight, - .aspectFillTopLeft, .aspectFillTopRight, .aspectFillBottomLeft, .aspectFillBottomRight: + .aspectFillTopLeft, .aspectFillTopRight, .aspectFillBottomLeft, .aspectFillBottomRight: image .resizingMode(.fill) .frame(size: Self.fillSize(for: imageContainer, in: geometry)) @@ -203,8 +201,7 @@ struct ImageView: View { .frame(size: targetSize, alignment: Self.alignment(for: contentMode)) .offset(Self.offset(forFocalPoint: focalPoint, targetSize: targetSize, fillSize: fillSize)) } - } - else { + } else { Color.placeholder } } diff --git a/Application/Sources/UI/Views/ImageViewLandscapePreviews.swift b/Application/Sources/UI/Views/ImageViewLandscapePreviews.swift index 6d4276333..62cc0866a 100644 --- a/Application/Sources/UI/Views/ImageViewLandscapePreviews.swift +++ b/Application/Sources/UI/Views/ImageViewLandscapePreviews.swift @@ -8,7 +8,7 @@ import SwiftUI struct ImageViewLandscapeCommon_Previews: PreviewProvider { private static let source = "https://www.rts.ch/2020/11/09/11/29/11737826.image/16x9/scale/width/400" - + static var previews: some View { Group { ImageView(source: source, contentMode: .aspectFit) @@ -26,7 +26,7 @@ struct ImageViewLandscapeCommon_Previews: PreviewProvider { struct ImageViewLandscapeAlignment_Previews: PreviewProvider { private static let source = "https://www.rts.ch/2020/11/09/11/29/11737826.image/16x9/scale/width/400" - + static var previews: some View { Group { ImageView(source: source, contentMode: .top) @@ -52,7 +52,7 @@ struct ImageViewLandscapeAlignment_Previews: PreviewProvider { struct ImageViewLandscapeAspectFit_Previews: PreviewProvider { private static let source = "https://www.rts.ch/2020/11/09/11/29/11737826.image/16x9/scale/width/400" - + static var previews: some View { Group { ImageView(source: source, contentMode: .aspectFitTop) @@ -78,7 +78,7 @@ struct ImageViewLandscapeAspectFit_Previews: PreviewProvider { struct ImageViewLandscapeAspectFill_Previews: PreviewProvider { private static let source = "https://www.rts.ch/2020/11/09/11/29/11737826.image/16x9/scale/width/400" - + static var previews: some View { Group { ImageView(source: source, contentMode: .aspectFillTop) @@ -104,7 +104,7 @@ struct ImageViewLandscapeAspectFill_Previews: PreviewProvider { struct ImageViewLandscapeAspectFillFocused_Previews: PreviewProvider { private static let source = "https://www.rts.ch/2020/11/09/11/29/11737826.image/16x9/scale/width/400" - + static var previews: some View { Group { ImageView(source: source, contentMode: .aspectFillFocused(relativeWidth: 0, relativeHeight: 0)) diff --git a/Application/Sources/UI/Views/ImageViewPortraitPreviews.swift b/Application/Sources/UI/Views/ImageViewPortraitPreviews.swift index 042311a8a..ff59719ec 100644 --- a/Application/Sources/UI/Views/ImageViewPortraitPreviews.swift +++ b/Application/Sources/UI/Views/ImageViewPortraitPreviews.swift @@ -8,7 +8,7 @@ import SwiftUI struct ImageViewPortraitCommon_Previews: PreviewProvider { private static let source = "https://www.rts.ch/2022/03/29/18/28/12979393.image/9x16/scale/width/400" - + static var previews: some View { Group { ImageView(source: source, contentMode: .aspectFit) @@ -26,7 +26,7 @@ struct ImageViewPortraitCommon_Previews: PreviewProvider { struct ImageViewPortraitAlignment_Previews: PreviewProvider { private static let source = "https://www.rts.ch/2022/03/29/18/28/12979393.image/9x16/scale/width/400" - + static var previews: some View { Group { ImageView(source: source, contentMode: .top) @@ -52,7 +52,7 @@ struct ImageViewPortraitAlignment_Previews: PreviewProvider { struct ImageViewPortraitAspectFit_Previews: PreviewProvider { private static let source = "https://www.rts.ch/2022/03/29/18/28/12979393.image/9x16/scale/width/400" - + static var previews: some View { Group { ImageView(source: source, contentMode: .aspectFitTop) @@ -78,7 +78,7 @@ struct ImageViewPortraitAspectFit_Previews: PreviewProvider { struct ImageViewPortraitAspectFill_Previews: PreviewProvider { private static let source = "https://www.rts.ch/2022/03/29/18/28/12979393.image/9x16/scale/width/400" - + static var previews: some View { Group { ImageView(source: source, contentMode: .aspectFillTop) @@ -104,7 +104,7 @@ struct ImageViewPortraitAspectFill_Previews: PreviewProvider { struct ImageViewPortraitAspectFillFocused_Previews: PreviewProvider { private static let source = "https://www.rts.ch/2022/03/29/18/28/12979393.image/9x16/scale/width/400" - + static var previews: some View { Group { ImageView(source: source, contentMode: .aspectFillFocused(relativeWidth: 0, relativeHeight: 0)) diff --git a/Application/Sources/UI/Views/LiveMediaCell.swift b/Application/Sources/UI/Views/LiveMediaCell.swift index 5bf042e46..3d1296892 100644 --- a/Application/Sources/UI/Views/LiveMediaCell.swift +++ b/Application/Sources/UI/Views/LiveMediaCell.swift @@ -12,30 +12,30 @@ import SwiftUI struct LiveMediaCell: View { @Binding private(set) var media: SRGMedia? @StateObject private var model = LiveMediaCellViewModel() - + @Environment(\.isSelected) private var isSelected - + init(media: SRGMedia?) { _media = .constant(media) } - + var body: some View { Group { -#if os(tvOS) - ExpandingCardButton(action: action) { + #if os(tvOS) + ExpandingCardButton(action: action) { + VisualView(model: model) + .aspectRatio(LiveMediaCellSize.aspectRatio, contentMode: .fit) + .unredactable() + .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint, traits: .isButton) + } + #else VisualView(model: model) .aspectRatio(LiveMediaCellSize.aspectRatio, contentMode: .fit) - .unredactable() - .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint, traits: .isButton) - } -#else - VisualView(model: model) - .aspectRatio(LiveMediaCellSize.aspectRatio, contentMode: .fit) - .redactable() - .selectionAppearance(when: isSelected && media != nil) - .cornerRadius(LayoutStandardViewCornerRadius) - .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint) -#endif + .redactable() + .selectionAppearance(when: isSelected && media != nil) + .cornerRadius(LayoutStandardViewCornerRadius) + .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint) + #endif } .redactedIfNil(media) .onAppear { @@ -45,26 +45,26 @@ struct LiveMediaCell: View { model.media = newValue } } - -#if os(tvOS) - private func action() { - if let media { - navigateToMedia(media, play: true) + + #if os(tvOS) + private func action() { + if let media { + navigateToMedia(media, play: true) + } } - } -#endif - + #endif + /// Behavior: h-exp, v-exp private struct VisualView: View { @ObservedObject var model: LiveMediaCellViewModel - + var body: some View { ZStack { ImageView(source: model.imageUrl) Color.srgGray16.opacity(0.7) DescriptionView(model: model) BlockingOverlay(media: model.media) - + if let progress = model.progress { ProgressBar(value: progress) .frame(height: LayoutProgressBarHeight) @@ -73,28 +73,28 @@ struct LiveMediaCell: View { } } } - + /// Behavior: h-exp, v-exp private struct DescriptionView: View { @ObservedObject var model: LiveMediaCellViewModel @Environment(\.uiHorizontalSizeClass) private var horizontalSizeClass - + private var padding: CGFloat { - return (horizontalSizeClass == .compact) ? 8 : constant(iOS: 10, tvOS: 16) + (horizontalSizeClass == .compact) ? 8 : constant(iOS: 10, tvOS: 16) } - + var body: some View { VStack(alignment: .leading, spacing: 0) { if let logoImage = model.logoImage { Image(uiImage: logoImage) .padding(.bottom, 4) } - + Text(model.title ?? "") .srgFont(.body, maximumSize: constant(iOS: 18, tvOS: nil)) .lineLimit(1) .foregroundColor(.white) - + if let subtitle = model.subtitle { Text(subtitle) .srgFont(.caption, maximumSize: constant(iOS: 15, tvOS: nil)) @@ -113,11 +113,11 @@ struct LiveMediaCell: View { private extension LiveMediaCell { var accessibilityLabel: String? { - return model.accessibilityLabel + model.accessibilityLabel } - + var accessibilityHint: String? { - return PlaySRGAccessibilityLocalizedString("Plays the content.", comment: "Media cell hint") + PlaySRGAccessibilityLocalizedString("Plays the content.", comment: "Media cell hint") } } @@ -125,15 +125,15 @@ private extension LiveMediaCell { enum LiveMediaCellSize { fileprivate static let aspectRatio: CGFloat = 16 / 9 - + private static let defaultItemWidth: CGFloat = constant(iOS: 210, tvOS: 375) - + static func swimlane(itemWidth: CGFloat = defaultItemWidth) -> NSCollectionLayoutSize { - return LayoutSwimlaneCellSize(itemWidth, aspectRatio, 0) + LayoutSwimlaneCellSize(itemWidth, aspectRatio, 0) } - + static func grid(layoutWidth: CGFloat, spacing: CGFloat) -> NSCollectionLayoutSize { - return LayoutGridCellSize(defaultItemWidth, aspectRatio, 0, layoutWidth, spacing, 2) + LayoutGridCellSize(defaultItemWidth, aspectRatio, 0, layoutWidth, spacing, 2) } } @@ -142,20 +142,20 @@ enum LiveMediaCellSize { struct LiveMediaCell_Previews: PreviewProvider { private static let media = Mock.media(.livestream) private static let size = LiveMediaCellSize.swimlane().previewSize - + static var previews: some View { -#if os(tvOS) - LiveMediaCell(media: media) - .previewLayout(.fixed(width: size.width, height: size.height)) -#else - Group { - LiveMediaCell(media: media) - .previewLayout(.fixed(width: size.width, height: size.height)) - .environment(\.horizontalSizeClass, .compact) + #if os(tvOS) LiveMediaCell(media: media) .previewLayout(.fixed(width: size.width, height: size.height)) - .environment(\.horizontalSizeClass, .regular) - } -#endif + #else + Group { + LiveMediaCell(media: media) + .previewLayout(.fixed(width: size.width, height: size.height)) + .environment(\.horizontalSizeClass, .compact) + LiveMediaCell(media: media) + .previewLayout(.fixed(width: size.width, height: size.height)) + .environment(\.horizontalSizeClass, .regular) + } + #endif } } diff --git a/Application/Sources/UI/Views/LiveMediaCellViewModel.swift b/Application/Sources/UI/Views/LiveMediaCellViewModel.swift index e45438e41..b866dfeef 100644 --- a/Application/Sources/UI/Views/LiveMediaCellViewModel.swift +++ b/Application/Sources/UI/Views/LiveMediaCellViewModel.swift @@ -12,25 +12,25 @@ final class LiveMediaCellViewModel: ObservableObject { registerForChannelUpdates(for: media) } } - + @Published private(set) var programComposition: SRGProgramComposition? @Published private(set) var date = Date() - + private var channelObserver: Any? - + init() { Timer.publish(every: 10, on: .main, in: .common) .autoconnect() .assign(to: &$date) } - + deinit { unregisterChannelUpdates() } - + private func registerForChannelUpdates(for media: SRGMedia?) { unregisterChannelUpdates() - + if let media, let channel = media.channel, media.contentType == .livestream { channelObserver = ChannelService.shared.addObserverForUpdates(with: channel, livestreamUid: media.uid) { [weak self] composition in guard let self else { return } @@ -38,7 +38,7 @@ final class LiveMediaCellViewModel: ObservableObject { } } } - + private func unregisterChannelUpdates() { programComposition = nil ChannelService.shared.removeObserver(channelObserver) @@ -49,60 +49,55 @@ final class LiveMediaCellViewModel: ObservableObject { extension LiveMediaCellViewModel { var channel: SRGChannel? { - return programComposition?.channel ?? media?.channel + programComposition?.channel ?? media?.channel } - + var logoImage: UIImage? { - return channel?.play_largeLogoImage + channel?.play_largeLogoImage } - + var program: SRGProgram? { - return programComposition?.play_program(at: date) + programComposition?.play_program(at: date) } - + var title: String? { if let channel { - return program?.title ?? channel.title - } - else if let media { - return MediaDescription.title(for: media, style: .date) - } - else { - return nil + program?.title ?? channel.title + } else if let media { + MediaDescription.title(for: media, style: .date) + } else { + nil } } - + var subtitle: String? { if let media, media.contentType == .scheduledLivestream { return MediaDescription.subtitle(for: media, style: .date) - } - else { + } else { guard let program else { return nil } let remainingTimeInterval = program.endDate.timeIntervalSince(date) let remainingTime = PlayRemainingTimeFormattedDuration(remainingTimeInterval) return String(format: NSLocalizedString("%@ remaining", comment: "Text displayed on live cells telling how much time remains for a program currently on air"), remainingTime) } } - + var progress: Double? { if channel != nil { guard let program else { return nil } let progress = date.timeIntervalSince(program.startDate) / program.endDate.timeIntervalSince(program.startDate) - return progress.clamped(to: 0...1) - } - else if let media, media.contentType == .scheduledLivestream, media.timeAvailability(at: date) == .available, - let startDate = media.startDate, - let endDate = media.endDate { + return progress.clamped(to: 0 ... 1) + } else if let media, media.contentType == .scheduledLivestream, media.timeAvailability(at: date) == .available, + let startDate = media.startDate, + let endDate = media.endDate { let progress = date.timeIntervalSince(startDate) / endDate.timeIntervalSince(startDate) - return progress.clamped(to: 0...1) - } - else { + return progress.clamped(to: 0 ... 1) + } else { return nil } } - + var imageUrl: URL? { - return url(for: program?.image, size: .small) ?? url(for: media?.image, size: .small) + url(for: program?.image, size: .small) ?? url(for: media?.image, size: .small) } } @@ -116,11 +111,9 @@ extension LiveMediaCellViewModel { label.append(", \(program.title)") } return label - } - else if let media { + } else if let media { return MediaDescription.cellAccessibilityLabel(for: media) - } - else { + } else { return nil } } diff --git a/Application/Sources/UI/Views/MailComposeView.swift b/Application/Sources/UI/Views/MailComposeView.swift index aa5c622a4..c902c5639 100644 --- a/Application/Sources/UI/Views/MailComposeView.swift +++ b/Application/Sources/UI/Views/MailComposeView.swift @@ -12,35 +12,35 @@ import UIKit struct MailComposeView: UIViewControllerRepresentable { @Environment(\.presentationMode) private var presentationMode - + fileprivate var toRecipients: [String]? fileprivate var subject: String? fileprivate var messageBody: String? - + static func canSendMail() -> Bool { - return MFMailComposeViewController.canSendMail() + MFMailComposeViewController.canSendMail() } - + class Coordinator: NSObject, MFMailComposeViewControllerDelegate { @Binding var presentationMode: PresentationMode - + init(presentation: Binding) { _presentationMode = presentation } - - func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) { + + func mailComposeController(_: MFMailComposeViewController, didFinishWith _: MFMailComposeResult, error _: Error?) { presentationMode.dismiss() } } - + func makeCoordinator() -> Coordinator { - return Coordinator(presentation: presentationMode) + Coordinator(presentation: presentationMode) } - + func makeUIViewController(context: Context) -> MFMailComposeViewController { - return viewController(context: context) + viewController(context: context) } - + func viewController(context: Context?) -> MFMailComposeViewController { let viewController = MFMailComposeViewController() if let coordinator = context?.coordinator { @@ -55,8 +55,8 @@ struct MailComposeView: UIViewControllerRepresentable { } return viewController } - - func updateUIViewController(_ uiViewController: MFMailComposeViewController, context: Context) { + + func updateUIViewController(_: MFMailComposeViewController, context _: Context) { // No updates } } @@ -69,13 +69,13 @@ extension MailComposeView { view.toRecipients = toRecipients return view } - + func subject(_ subject: String) -> Self { var view = self view.subject = subject return view } - + func messageBody(_ messageBody: String) -> Self { var view = self view.messageBody = messageBody diff --git a/Application/Sources/UI/Views/MediaCell.swift b/Application/Sources/UI/Views/MediaCell.swift index a07475883..9205b557a 100644 --- a/Application/Sources/UI/Views/MediaCell.swift +++ b/Application/Sources/UI/Views/MediaCell.swift @@ -15,7 +15,7 @@ struct MediaCell: View, PrimaryColorSettable, SecondaryColorSettable { case horizontal case adaptive } - + enum Style { /// Show information emphasis case show @@ -26,116 +26,115 @@ struct MediaCell: View, PrimaryColorSettable, SecondaryColorSettable { /// Time information emphasis case time } - + let media: SRGMedia? let style: Style let layout: Layout let action: (() -> Void)? - - internal var primaryColor: Color = .srgGrayD2 - internal var secondaryColor: Color = .srgGray96 - + + var primaryColor: Color = .srgGrayD2 + var secondaryColor: Color = .srgGray96 + fileprivate var onFocusAction: ((Bool) -> Void)? - + @State private var isFocused = false - + @Environment(\.isEditing) private var isEditing @Environment(\.isSelected) private var isSelected @Environment(\.uiHorizontalSizeClass) private var horizontalSizeClass - + private var direction: StackDirection { if layout == .horizontal || (layout == .adaptive && horizontalSizeClass == .compact) { - return .horizontal - } - else { - return .vertical + .horizontal + } else { + .vertical } } - + private var horizontalPadding: CGFloat { - return direction == .vertical ? 0 : constant(iOS: 10, tvOS: 20) + direction == .vertical ? 0 : constant(iOS: 10, tvOS: 20) } - + private var verticalPadding: CGFloat { - return direction == .vertical ? constant(iOS: 5, tvOS: 15) : 0 + direction == .vertical ? constant(iOS: 5, tvOS: 15) : 0 } - + private var hasSelectionAppearance: Bool { - return isSelected && media != nil + isSelected && media != nil } - + init(media: SRGMedia?, style: Style, layout: Layout = .adaptive, action: (() -> Void)? = nil) { self.media = media self.style = style self.layout = layout self.action = action } - + var body: some View { Group { -#if os(tvOS) - LabeledCardButton(aspectRatio: MediaCellSize.aspectRatio, action: action ?? defaultAction) { - MediaVisualView(media: media, size: .small) - .onParentFocusChange(perform: onFocusChange) - .unredactable() - .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint, traits: accessibilityTraits) - } label: { - DescriptionView(media: media, style: style) - .primaryColor(primaryColor) - .secondaryColor(secondaryColor) - .padding(.top, verticalPadding) - } -#else - Stack(direction: .vertical, spacing: 0) { - Stack(direction: direction, spacing: 0) { - MediaVisualView(media: media, size: .small, embeddedDirection: direction) - .aspectRatio(MediaCellSize.aspectRatio, contentMode: .fit) - .selectionAppearance(when: hasSelectionAppearance, while: isEditing) - .cornerRadius(LayoutStandardViewCornerRadius) - .redactable() - .layoutPriority(1) - DescriptionView(media: media, style: style, embeddedDirection: direction) + #if os(tvOS) + LabeledCardButton(aspectRatio: MediaCellSize.aspectRatio, action: action ?? defaultAction) { + MediaVisualView(media: media, size: .small) + .onParentFocusChange(perform: onFocusChange) + .unredactable() + .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint, traits: accessibilityTraits) + } label: { + DescriptionView(media: media, style: style) .primaryColor(primaryColor) .secondaryColor(secondaryColor) - .selectionAppearance(.transluscent, when: hasSelectionAppearance, while: isEditing) - .padding(.leading, horizontalPadding) .padding(.top, verticalPadding) - if direction == .horizontal, style == .dateAndSummary, horizontalSizeClass == .regular, let media { - MediaMoreButton(media: media) + } + #else + Stack(direction: .vertical, spacing: 0) { + Stack(direction: direction, spacing: 0) { + MediaVisualView(media: media, size: .small, embeddedDirection: direction) + .aspectRatio(MediaCellSize.aspectRatio, contentMode: .fit) + .selectionAppearance(when: hasSelectionAppearance, while: isEditing) + .cornerRadius(LayoutStandardViewCornerRadius) + .redactable() + .layoutPriority(1) + DescriptionView(media: media, style: style, embeddedDirection: direction) + .primaryColor(primaryColor) + .secondaryColor(secondaryColor) + .selectionAppearance(.transluscent, when: hasSelectionAppearance, while: isEditing) + .padding(.leading, horizontalPadding) + .padding(.top, verticalPadding) + if direction == .horizontal, style == .dateAndSummary, horizontalSizeClass == .regular, let media { + MediaMoreButton(media: media) + } } } - } - .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint, traits: accessibilityTraits) -#endif + .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint, traits: accessibilityTraits) + #endif } .redactedIfNil(media) } - -#if os(tvOS) - private func defaultAction() { - if let media { - navigateToMedia(media) + + #if os(tvOS) + private func defaultAction() { + if let media { + navigateToMedia(media) + } } - } - - private func onFocusChange(focused: Bool) { - isFocused = focused - - if let onFocusAction { - onFocusAction(focused) + + private func onFocusChange(focused: Bool) { + isFocused = focused + + if let onFocusAction { + onFocusAction(focused) + } } - } -#endif - + #endif + /// Behavior: h-exp, v-exp private struct DescriptionView: View, PrimaryColorSettable, SecondaryColorSettable { let media: SRGMedia? let style: MediaCell.Style let embeddedDirection: StackDirection - - internal var primaryColor: Color = .srgGrayD2 - internal var secondaryColor: Color = .srgGray96 - + + var primaryColor: Color = .srgGrayD2 + var secondaryColor: Color = .srgGray96 + init( media: SRGMedia?, style: MediaCell.Style, @@ -145,56 +144,55 @@ struct MediaCell: View, PrimaryColorSettable, SecondaryColorSettable { self.style = style self.embeddedDirection = embeddedDirection } - + private var availabilityBadgeProperties: MediaDescription.BadgeProperties? { guard let media else { return nil } return MediaDescription.availabilityBadgeProperties(for: media) } - + @Environment(\.uiHorizontalSizeClass) private var horizontalSizeClass - + private var subtitle: String? { guard let media else { return .placeholder(length: 15) } return MediaDescription.subtitle(for: media, style: mediaDescriptionStyle) } - + private var title: String? { guard let media else { return .placeholder(length: 8) } return MediaDescription.title(for: media, style: mediaDescriptionStyle) } - + private var summary: String? { guard horizontalSizeClass == .regular, style == .dateAndSummary else { return nil } - + guard let media else { return .placeholder(length: 15) } return MediaDescription.summary(for: media) } - + private var mediaDescriptionStyle: MediaDescription.Style { switch style { case .show: - return .show + .show case .date, .dateAndSummary: - return .date + .date case .time: - return .time + .time } } - + private var titleLineLimit: Int { - if horizontalSizeClass == .regular && style == .dateAndSummary { - return 1 - } - else { - return embeddedDirection == .horizontal ? 3 : 2 + if horizontalSizeClass == .regular, style == .dateAndSummary { + 1 + } else { + embeddedDirection == .horizontal ? 3 : 2 } } - + private var bottomPadding: CGFloat { // Allow 3 lines for title, with a badge and no subtitles - return embeddedDirection == .horizontal ? -2 : 0 + embeddedDirection == .horizontal ? -2 : 0 } - + var body: some View { VStack(alignment: .leading, spacing: 0) { if embeddedDirection == .horizontal, let properties = availabilityBadgeProperties { @@ -244,17 +242,17 @@ private extension MediaCell { guard let media else { return nil } return MediaDescription.cellAccessibilityLabel(for: media) } - + var accessibilityHint: String? { - return !isEditing ? PlaySRGAccessibilityLocalizedString("Plays the content.", comment: "Media cell hint") : PlaySRGAccessibilityLocalizedString("Toggles selection.", comment: "Media cell hint in edit mode") + !isEditing ? PlaySRGAccessibilityLocalizedString("Plays the content.", comment: "Media cell hint") : PlaySRGAccessibilityLocalizedString("Toggles selection.", comment: "Media cell hint in edit mode") } - + var accessibilityTraits: AccessibilityTraits { -#if os(tvOS) - return .isButton -#else - return isSelected ? .isSelected : [] -#endif + #if os(tvOS) + return .isButton + #else + return isSelected ? .isSelected : [] + #endif } } @@ -262,25 +260,25 @@ private extension MediaCell { final class MediaCellSize: NSObject { fileprivate static let aspectRatio: CGFloat = 16 / 9 - + private static let defaultItemWidth: CGFloat = constant(iOS: 210, tvOS: 375) private static let heightOffset: CGFloat = constant(iOS: 65, tvOS: 140) - + static func swimlane(itemWidth: CGFloat = defaultItemWidth) -> NSCollectionLayoutSize { - return LayoutSwimlaneCellSize(itemWidth, aspectRatio, heightOffset) + LayoutSwimlaneCellSize(itemWidth, aspectRatio, heightOffset) } - + static func grid(layoutWidth: CGFloat, spacing: CGFloat) -> NSCollectionLayoutSize { - return LayoutGridCellSize(defaultItemWidth, aspectRatio, heightOffset, layoutWidth, spacing, 1) + LayoutGridCellSize(defaultItemWidth, aspectRatio, heightOffset, layoutWidth, spacing, 1) } - + static func fullWidth(horizontalSizeClass: UIUserInterfaceSizeClass = .compact) -> NSCollectionLayoutSize { let height = height(horizontalSizeClass: horizontalSizeClass) return NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(CGFloat(height))) } - + static func height(horizontalSizeClass: UIUserInterfaceSizeClass) -> CGFloat { - return horizontalSizeClass == .compact ? constant(iOS: 84, tvOS: 120) : constant(iOS: 104, tvOS: 120) + horizontalSizeClass == .compact ? constant(iOS: 84, tvOS: 120) : constant(iOS: 104, tvOS: 120) } } @@ -292,35 +290,35 @@ struct MediaCell_Previews: PreviewProvider { private static let horizontalLargeListLayoutSize = MediaCellSize.fullWidth(horizontalSizeClass: .regular).previewSize private static let style = MediaCell.Style.show private static let largeListStyle = MediaCell.Style.dateAndSummary - + static var previews: some View { Group { - MediaCell(media: Mock.media(), style: Self.style, layout: .vertical) - MediaCell(media: Mock.media(.noShow), style: Self.style, layout: .vertical) - MediaCell(media: Mock.media(.rich), style: Self.style, layout: .vertical) - MediaCell(media: Mock.media(.overflow), style: Self.style, layout: .vertical) - MediaCell(media: Mock.media(.nineSixteen), style: Self.style, layout: .vertical) + MediaCell(media: Mock.media(), style: style, layout: .vertical) + MediaCell(media: Mock.media(.noShow), style: style, layout: .vertical) + MediaCell(media: Mock.media(.rich), style: style, layout: .vertical) + MediaCell(media: Mock.media(.overflow), style: style, layout: .vertical) + MediaCell(media: Mock.media(.nineSixteen), style: style, layout: .vertical) } .previewLayout(.fixed(width: verticalLayoutSize.width, height: verticalLayoutSize.height)) - -#if os(iOS) - Group { - MediaCell(media: Mock.media(), style: Self.style, layout: .horizontal) - MediaCell(media: Mock.media(.noShow), style: Self.style, layout: .horizontal) - MediaCell(media: Mock.media(.rich), style: Self.style, layout: .horizontal) - MediaCell(media: Mock.media(.overflow), style: Self.style, layout: .horizontal) - MediaCell(media: Mock.media(.nineSixteen), style: Self.style, layout: .horizontal) - } - .previewLayout(.fixed(width: horizontalLayoutSize.width, height: horizontalLayoutSize.height)) - - Group { - MediaCell(media: Mock.media(), style: Self.largeListStyle, layout: .horizontal) - MediaCell(media: Mock.media(.noShow), style: Self.largeListStyle, layout: .horizontal) - MediaCell(media: Mock.media(.rich), style: Self.largeListStyle, layout: .horizontal) - MediaCell(media: Mock.media(.overflow), style: Self.largeListStyle, layout: .horizontal) - MediaCell(media: Mock.media(.nineSixteen), style: Self.largeListStyle, layout: .horizontal) - } - .previewLayout(.fixed(width: horizontalLargeListLayoutSize.width, height: horizontalLargeListLayoutSize.height)) -#endif + + #if os(iOS) + Group { + MediaCell(media: Mock.media(), style: style, layout: .horizontal) + MediaCell(media: Mock.media(.noShow), style: style, layout: .horizontal) + MediaCell(media: Mock.media(.rich), style: style, layout: .horizontal) + MediaCell(media: Mock.media(.overflow), style: style, layout: .horizontal) + MediaCell(media: Mock.media(.nineSixteen), style: style, layout: .horizontal) + } + .previewLayout(.fixed(width: horizontalLayoutSize.width, height: horizontalLayoutSize.height)) + + Group { + MediaCell(media: Mock.media(), style: largeListStyle, layout: .horizontal) + MediaCell(media: Mock.media(.noShow), style: largeListStyle, layout: .horizontal) + MediaCell(media: Mock.media(.rich), style: largeListStyle, layout: .horizontal) + MediaCell(media: Mock.media(.overflow), style: largeListStyle, layout: .horizontal) + MediaCell(media: Mock.media(.nineSixteen), style: largeListStyle, layout: .horizontal) + } + .previewLayout(.fixed(width: horizontalLargeListLayoutSize.width, height: horizontalLargeListLayoutSize.height)) + #endif } } diff --git a/Application/Sources/UI/Views/MediaMoreButton.swift b/Application/Sources/UI/Views/MediaMoreButton.swift index b2dba5a9a..44e1a933e 100644 --- a/Application/Sources/UI/Views/MediaMoreButton.swift +++ b/Application/Sources/UI/Views/MediaMoreButton.swift @@ -8,14 +8,14 @@ import SwiftUI struct MediaMoreButton: UIViewRepresentable { typealias UIViewType = UIButton - + let media: SRGMedia - - func makeUIView(context: Context) -> UIButton { + + func makeUIView(context _: Context) -> UIButton { let button = UIButton(type: .custom) button.setImage(UIImage(resource: .ellipsis), for: .normal) button.tintColor = .srgGrayD2 - + button.showsMenuAsPrimaryAction = true button.menu = UIMenu() button.addAction(UIAction(handler: { _ in @@ -23,14 +23,14 @@ struct MediaMoreButton: UIViewRepresentable { button.menu = ContextMenu.menu(for: media, in: viewController) } }), for: .menuActionTriggered) - + button.setContentHuggingPriority(.required, for: .horizontal) button.setContentHuggingPriority(.required, for: .vertical) - + return button } - - func updateUIView(_ uiView: UIButton, context: Context) { + + func updateUIView(_: UIButton, context _: Context) { // No update logic required } } @@ -39,7 +39,7 @@ struct MediaMoreButton: UIViewRepresentable { private extension MediaMoreButton { var accessibilityLabel: String? { - return PlaySRGAccessibilityLocalizedString("More", comment: "More button label") + PlaySRGAccessibilityLocalizedString("More", comment: "More button label") } } diff --git a/Application/Sources/UI/Views/MediaVisualView.swift b/Application/Sources/UI/Views/MediaVisualView.swift index b06b89ce9..b77770a03 100644 --- a/Application/Sources/UI/Views/MediaVisualView.swift +++ b/Application/Sources/UI/Views/MediaVisualView.swift @@ -12,15 +12,15 @@ import SwiftUI struct MediaVisualView: View { @Binding private(set) var media: SRGMedia? @StateObject private var model = MediaVisualViewModel() - + let size: SRGImageSize let contentMode: ImageView.ContentMode let embeddedDirection: StackDirection - + @Binding private var content: (SRGMedia?) -> Content - + let padding: CGFloat = constant(iOS: 6, tvOS: 16) - + init( media: SRGMedia?, size: SRGImageSize, @@ -34,24 +34,24 @@ struct MediaVisualView: View { self.embeddedDirection = embeddedDirection _content = .constant(content) } - + var body: some View { ZStack { ImageView(source: model.imageUrl(for: size), contentMode: contentMode) .background(Color.thumbnailBackground) content(media) BlockingOverlay(media: media) - + if embeddedDirection == .vertical, let properties = model.availabilityBadgeProperties { Badge(text: properties.text, color: Color(properties.color)) .padding([.top, .leading], padding) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - + AttributesView(model: model) .padding([.bottom, .horizontal], padding) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) - + if let progress = model.progress { ProgressBar(value: progress) .opacity(progress != 0 ? 1 : 0) @@ -66,26 +66,26 @@ struct MediaVisualView: View { model.media = newValue } } - + /// Behavior: h-exp, v-hug private struct AttributesView: View { @ObservedObject var model: MediaVisualViewModel - + @AppStorage(PlaySRGSettingSubtitleAvailabilityDisplayed) var isSubtitleAvailabilityDisplayed = false @AppStorage(PlaySRGSettingAudioDescriptionAvailabilityDisplayed) var isAudioDescriptionAvailabilityDisplayed = false - + @Accessibility(\.isVoiceOverRunning) private var isVoiceOverRunning - + private var canDisplaySubtitleAvailability: Bool { guard !ApplicationConfiguration.shared.isSubtitleAvailabilityHidden else { return false } return isVoiceOverRunning || isSubtitleAvailabilityDisplayed } - + private var canDisplayAudioDescriptionAvailability: Bool { guard !ApplicationConfiguration.shared.isAudioDescriptionAvailabilityHidden else { return false } return isVoiceOverRunning || isAudioDescriptionAvailabilityDisplayed } - + var body: some View { HStack(spacing: 6) { Spacer() @@ -131,7 +131,7 @@ struct MediaVisualView_Previews: PreviewProvider { userDefaults.setValue(true, forKey: PlaySRGSettingAudioDescriptionAvailabilityDisplayed) return userDefaults }() - + static var previews: some View { Group { MediaVisualView(media: Mock.media(.standard), size: .small) diff --git a/Application/Sources/UI/Views/MediaVisualViewModel.swift b/Application/Sources/UI/Views/MediaVisualViewModel.swift index ef072181e..11acdfe76 100644 --- a/Application/Sources/UI/Views/MediaVisualViewModel.swift +++ b/Application/Sources/UI/Views/MediaVisualViewModel.swift @@ -11,7 +11,7 @@ import Combine final class MediaVisualViewModel: ObservableObject { @Published var media: SRGMedia? @Published private(set) var progress: Double? - + init() { // Drop initial values; relevant values are first assigned when the view appears $media @@ -26,40 +26,40 @@ final class MediaVisualViewModel: ObservableObject { .receive(on: DispatchQueue.main) .assign(to: &$progress) } - + func imageUrl(for size: SRGImageSize) -> URL? { - return url(for: media?.image, size: size) + url(for: media?.image, size: size) } - + var availabilityBadgeProperties: MediaDescription.BadgeProperties? { guard let media else { return nil } return MediaDescription.availabilityBadgeProperties(for: media) } - + var is360: Bool { - return media?.presentation == .presentation360 + media?.presentation == .presentation360 } - + var isMultiAudioAvailable: Bool { guard let media else { return false } return media.play_isMultiAudioAvailable } - + var isAudioDescriptionAvailable: Bool { guard let media else { return false } return media.play_isAudioDescriptionAvailable } - + var areSubtitlesAvailable: Bool { guard let media else { return false } return media.play_areSubtitlesAvailable } - + var youthProtectionColor: SRGYouthProtectionColor? { let youthProtectionColor = media?.youthProtectionColor return youthProtectionColor != SRGYouthProtectionColor.none ? youthProtectionColor : nil } - + var duration: Double? { guard let media else { return nil } return MediaDescription.duration(for: media) diff --git a/Application/Sources/UI/Views/MoreCell.swift b/Application/Sources/UI/Views/MoreCell.swift index f7eacd974..674e0270f 100644 --- a/Application/Sources/UI/Views/MoreCell.swift +++ b/Application/Sources/UI/Views/MoreCell.swift @@ -12,16 +12,36 @@ struct MoreCell: View { let section: Content.Section let imageVariant: SRGImageVariant let filter: SectionFiltering? - + static let iconHeight: CGFloat = constant(iOS: 60, tvOS: 100) - + fileprivate static func aspectRatio(for imageVariant: SRGImageVariant) -> CGFloat { - return imageVariant == .poster ? 2 / 3 : 16 / 9 + switch imageVariant { + case .poster: + 2 / 3 + case .podcast: + 1 + case .default: + 16 / 9 + } } - + var body: some View { -#if os(tvOS) - LabeledCardButton(aspectRatio: Self.aspectRatio(for: imageVariant), action: action) { + #if os(tvOS) + LabeledCardButton(aspectRatio: Self.aspectRatio(for: imageVariant), action: action) { + Image(.chevronLarge) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: Self.iconHeight) + .foregroundColor(.srgGrayD2) + .opacity(0.8) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.srgGray33) + .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint, traits: .isButton) + } label: { + Color.clear + } + #else Image(.chevronLarge) .resizable() .aspectRatio(contentMode: .fit) @@ -29,42 +49,29 @@ struct MoreCell: View { .foregroundColor(.srgGrayD2) .opacity(0.8) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.srgGray33) - .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint, traits: .isButton) - } label: { - Color.clear - } -#else - Image(.chevronLarge) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(height: Self.iconHeight) - .foregroundColor(.srgGrayD2) - .opacity(0.8) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .aspectRatio(Self.aspectRatio(for: imageVariant), contentMode: .fit) - .cornerRadius(LayoutStandardViewCornerRadius) - .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint) - .frame(maxHeight: .infinity, alignment: .top) -#endif + .aspectRatio(Self.aspectRatio(for: imageVariant), contentMode: .fit) + .cornerRadius(LayoutStandardViewCornerRadius) + .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint) + .frame(maxHeight: .infinity, alignment: .top) + #endif } - -#if os(tvOS) - private func action() { - navigateToSection(section, filter: filter) - } -#endif + + #if os(tvOS) + private func action() { + navigateToSection(section, filter: filter) + } + #endif } // MARK: Accessibility private extension MoreCell { var accessibilityLabel: String? { - return PlaySRGAccessibilityLocalizedString("More", comment: "More button label") + PlaySRGAccessibilityLocalizedString("More", comment: "More button label") } - + var accessibilityHint: String? { - return PlaySRGAccessibilityLocalizedString("Opens details.", comment: "More button hint") + PlaySRGAccessibilityLocalizedString("Opens details.", comment: "More button hint") } } diff --git a/Application/Sources/UI/Views/NotificationCell.swift b/Application/Sources/UI/Views/NotificationCell.swift index f868f1989..cdbb48d96 100644 --- a/Application/Sources/UI/Views/NotificationCell.swift +++ b/Application/Sources/UI/Views/NotificationCell.swift @@ -10,14 +10,14 @@ import SwiftUI struct NotificationCell: View { let notification: UserNotification - + @Environment(\.isEditing) private var isEditing @Environment(\.isSelected) private var isSelected - + private var imageUrl: URL? { - return url(for: notification.image, size: .small) + url(for: notification.image, size: .small) } - + var body: some View { HStack(spacing: 0) { ImageView(source: imageUrl) @@ -30,24 +30,23 @@ struct NotificationCell: View { } .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint, traits: accessibilityTraits) } - + /// Behavior: h-exp, v-exp private struct DescriptionView: View { let notification: UserNotification - + private var title: String { let date = DateFormatter.play_relativeShortDate.string(from: notification.date) - + let title = notification.title if !title.isEmpty { // Unbreakable spaces before / after the separator return "\(title) · \(date)" - } - else { + } else { return date } } - + var body: some View { VStack(alignment: .leading) { HStack(alignment: .top) { @@ -61,7 +60,7 @@ struct NotificationCell: View { } } .srgFont(.subtitle1) - + Text(notification.body) .srgFont(.H4) .lineLimit(2) @@ -80,18 +79,17 @@ private extension NotificationCell { let title = notification.title if !title.isEmpty { return "\(title), \(notification.body)" - } - else { + } else { return notification.body } } - + var accessibilityHint: String? { - return isEditing ? PlaySRGAccessibilityLocalizedString("Toggles selection.", comment: "Notification cell hint in edit mode") : nil + isEditing ? PlaySRGAccessibilityLocalizedString("Toggles selection.", comment: "Notification cell hint in edit mode") : nil } - + var accessibilityTraits: AccessibilityTraits { - return isSelected ? .isSelected : [] + isSelected ? .isSelected : [] } } @@ -99,7 +97,7 @@ private extension NotificationCell { class NotificationCellSize: NSObject { @objc static func fullWidth() -> NSCollectionLayoutSize { - return NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(84)) + NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(84)) } } diff --git a/Application/Sources/UI/Views/PlayNavigationView.swift b/Application/Sources/UI/Views/PlayNavigationView.swift index 48968ded2..606888766 100644 --- a/Application/Sources/UI/Views/PlayNavigationView.swift +++ b/Application/Sources/UI/Views/PlayNavigationView.swift @@ -8,16 +8,16 @@ import SRGAppearanceSwift import SwiftUI import SwiftUIIntrospect -func PlayNavigationView(@ViewBuilder content: () -> Content) -> AnyView { - return NavigationView(content: content) +func PlayNavigationView(@ViewBuilder content: () -> some View) -> AnyView { + NavigationView(content: content) .navigationViewStyle(.stack) .introspect(.navigationView(style: .stack), on: .iOS(.v14, .v15, .v16, .v17), .tvOS(.v14, .v15, .v16, .v17)) { let navigationBar = $0.navigationBar -#if os(iOS) - navigationBar.largeTitleTextAttributes = [ - .font: SRGFont.font(family: .display, weight: .bold, fixedSize: 34) as UIFont - ] -#endif + #if os(iOS) + navigationBar.largeTitleTextAttributes = [ + .font: SRGFont.font(family: .display, weight: .bold, fixedSize: 34) as UIFont + ] + #endif navigationBar.titleTextAttributes = [ .font: SRGFont.font(family: .display, weight: .semibold, fixedSize: 17) as UIFont ] diff --git a/Application/Sources/UI/Views/PlaySection.swift b/Application/Sources/UI/Views/PlaySection.swift index 94b39651d..7ea3bc4e0 100644 --- a/Application/Sources/UI/Views/PlaySection.swift +++ b/Application/Sources/UI/Views/PlaySection.swift @@ -7,15 +7,15 @@ import SRGAppearanceSwift import SwiftUI -func PlaySection(@ViewBuilder content: () -> Content, @ViewBuilder header: () -> Header, @ViewBuilder footer: () -> Footer) -> Section { - return Section { +func PlaySection(@ViewBuilder content: () -> some View, @ViewBuilder header: () -> some View, @ViewBuilder footer: () -> some View) -> Section { + Section { content() .srgFont(.body) -#if os(tvOS) - // tvOS 17.0 introduced a new issue when presenting modal, the default focused appearance is broken after modal presentation dismissal. See https://github.com/SRGSSR/playsrg-apple/issues/336 + #if os(tvOS) + // tvOS 17.0 introduced a new issue when presenting modal, the default focused appearance is broken after modal presentation dismissal. See https://github.com/SRGSSR/playsrg-apple/issues/336 .foregroundColor(.white) .listRowBackground(Color.srgGray33.cornerRadius(10)) -#endif + #endif .eraseToAnyView() } header: { header() @@ -30,20 +30,20 @@ func PlaySection(@ViewBuilder content } } -func PlaySection(@ViewBuilder content: () -> Content, @ViewBuilder header: () -> Header) -> Section { - return PlaySection(content: content, header: header) { +func PlaySection(@ViewBuilder content: () -> some View, @ViewBuilder header: () -> some View) -> Section { + PlaySection(content: content, header: header) { EmptyView() } } -func PlaySection(@ViewBuilder content: () -> Content, @ViewBuilder footer: () -> Footer) -> Section { - return PlaySection(content: content, header: { +func PlaySection(@ViewBuilder content: () -> some View, @ViewBuilder footer: () -> some View) -> Section { + PlaySection(content: content, header: { EmptyView() }, footer: footer) } -func PlaySection(@ViewBuilder content: () -> Content) -> Section { - return PlaySection(content: content) { +func PlaySection(@ViewBuilder content: () -> some View) -> Section { + PlaySection(content: content) { EmptyView() } footer: { EmptyView() @@ -53,6 +53,6 @@ func PlaySection(@ViewBuilder content: () -> Content) -> Section< extension Color { static var play_sectionSecondary: Color { // tvOS 17.0 introduced a new issue when presenting modal, the default focused appearance is broken after modal presentation dismissal. See https://github.com/SRGSSR/playsrg-apple/issues/336 - return constant(iOS: .secondary, tvOS: .white) + constant(iOS: .secondary, tvOS: .white) } } diff --git a/Application/Sources/UI/Views/ProgressBar.swift b/Application/Sources/UI/Views/ProgressBar.swift index 4db51ab68..60b17bffa 100644 --- a/Application/Sources/UI/Views/ProgressBar.swift +++ b/Application/Sources/UI/Views/ProgressBar.swift @@ -12,7 +12,7 @@ import SwiftUI /// Behavior: h-exp, v-exp struct ProgressBar: View { let value: Double - + var body: some View { GeometryReader { geometry in ZStack(alignment: .leading) { @@ -23,9 +23,9 @@ struct ProgressBar: View { } } } - + init(value: Double) { - self.value = value.clamped(to: 0...1) + self.value = value.clamped(to: 0 ... 1) } } diff --git a/Application/Sources/UI/Views/RefreshControl.swift b/Application/Sources/UI/Views/RefreshControl.swift index b61111623..d144487e3 100644 --- a/Application/Sources/UI/Views/RefreshControl.swift +++ b/Application/Sources/UI/Views/RefreshControl.swift @@ -9,12 +9,12 @@ import UIKit class RefreshControl: UIRefreshControl { override init() { super.init() - + tintColor = .white layer.zPosition = -1.0 // Ensure the refresh control appears behind the cells isUserInteractionEnabled = false // Avoid conflicts with table view cell interactions when using VoiceOver } - + required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) } diff --git a/Application/Sources/UI/Views/ResponderChain.swift b/Application/Sources/UI/Views/ResponderChain.swift index a3fd524ab..6fd2b15c0 100644 --- a/Application/Sources/UI/Views/ResponderChain.swift +++ b/Application/Sources/UI/Views/ResponderChain.swift @@ -16,7 +16,7 @@ extension View { * Behavior: h-neu, v-neu */ func responderChain(from firstResponder: FirstResponder) -> some View { - return background(ResponderChain(firstResponder: firstResponder)) + background(ResponderChain(firstResponder: firstResponder)) } } @@ -27,12 +27,12 @@ extension View { */ private struct ResponderChain: UIViewRepresentable { let firstResponder: FirstResponder - - func makeUIView(context: Context) -> UIView { - return UIView() + + func makeUIView(context _: Context) -> UIView { + UIView() } - - func updateUIView(_ uiView: UIView, context: Context) { + + func updateUIView(_ uiView: UIView, context _: Context) { firstResponder.view = uiView } } @@ -47,13 +47,13 @@ private struct ResponderChain: UIViewRepresentable { // (which would lead to undefined behavior). Declaring it as a property wrapper is only syntactic sugar to provide // for a more expressive formalism with no need to call the default constructor. fileprivate weak var view: UIView? - + var wrappedValue: FirstResponder { - return self + self } - + @discardableResult func sendAction(_ action: Selector, for event: UIEvent? = nil) -> Bool { - return UIApplication.shared.sendAction(action, to: nil, from: view, for: event) + UIApplication.shared.sendAction(action, to: nil, from: view, for: event) } } diff --git a/Application/Sources/UI/Views/SafariView.swift b/Application/Sources/UI/Views/SafariView.swift index 626cc355f..8caaa200a 100644 --- a/Application/Sources/UI/Views/SafariView.swift +++ b/Application/Sources/UI/Views/SafariView.swift @@ -11,12 +11,12 @@ import SwiftUI struct SafariView: UIViewControllerRepresentable { let url: URL - - func makeUIViewController(context: Context) -> SFSafariViewController { - return SFSafariViewController(url: url) + + func makeUIViewController(context _: Context) -> SFSafariViewController { + SFSafariViewController(url: url) } - - func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) { + + func updateUIViewController(_: SFSafariViewController, context _: Context) { // Never updated } } diff --git a/Application/Sources/UI/Views/SearchBar.swift b/Application/Sources/UI/Views/SearchBar.swift index 0e8e00800..63a2c1cf6 100644 --- a/Application/Sources/UI/Views/SearchBar.swift +++ b/Application/Sources/UI/Views/SearchBar.swift @@ -11,19 +11,18 @@ import UIKit */ final class SearchBar: UISearchBar { var textField: UITextField? { - return Self.textField(in: self) + Self.textField(in: self) } - + override func layoutSubviews() { super.layoutSubviews() showsCancelButton = false } - + private static func textField(in view: UIView) -> UITextField? { if let textField = view as? UITextField { return textField - } - else { + } else { for subview in view.subviews { if let textField = textField(in: subview) { return textField diff --git a/Application/Sources/UI/Views/SearchBarView.swift b/Application/Sources/UI/Views/SearchBarView.swift index 4ba851076..05eac6dfb 100644 --- a/Application/Sources/UI/Views/SearchBarView.swift +++ b/Application/Sources/UI/Views/SearchBarView.swift @@ -12,29 +12,29 @@ struct SearchBarView: UIViewRepresentable { @Binding var text: String let placeholder: String let autocapitalizationType: UITextAutocapitalizationType - + init(text: Binding, placeholder: String = "", autocapitalizationType: UITextAutocapitalizationType = .sentences) { _text = text self.placeholder = placeholder self.autocapitalizationType = autocapitalizationType } - + final class Cordinator: NSObject, UISearchBarDelegate { @Binding var text: String - + init(text: Binding) { _text = text } - - func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + + func searchBar(_: UISearchBar, textDidChange searchText: String) { text = searchText } } - + func makeCoordinator() -> Cordinator { - return Cordinator(text: $text) + Cordinator(text: $text) } - + func makeUIView(context: Context) -> UISearchBar { let searchBar = UISearchBar() searchBar.backgroundImage = UIImage() @@ -43,8 +43,8 @@ struct SearchBarView: UIViewRepresentable { searchBar.delegate = context.coordinator return searchBar } - - func updateUIView(_ uiView: UISearchBar, context: Context) { + + func updateUIView(_ uiView: UISearchBar, context _: Context) { uiView.text = text } } diff --git a/Application/Sources/UI/Views/SheetTextView.swift b/Application/Sources/UI/Views/SheetTextView.swift index 840aa2abd..0e2595d9b 100644 --- a/Application/Sources/UI/Views/SheetTextView.swift +++ b/Application/Sources/UI/Views/SheetTextView.swift @@ -12,7 +12,7 @@ import SwiftUI /// Behavior: h-exp, v-exp struct SheetTextView: View { let content: String - + var body: some View { VStack(spacing: 18) { Handle { diff --git a/Application/Sources/UI/Views/ShowAccessCell.swift b/Application/Sources/UI/Views/ShowAccessCell.swift index b06ed348c..069ec7c07 100644 --- a/Application/Sources/UI/Views/ShowAccessCell.swift +++ b/Application/Sources/UI/Views/ShowAccessCell.swift @@ -19,35 +19,35 @@ import SwiftUI /// Behavior: h-exp, v-exp struct ShowAccessCell: View, PrimaryColorSettable { let style: Style - - internal var primaryColor: Color = .srgGrayD2 - + + var primaryColor: Color = .srgGrayD2 + @FirstResponder private var firstResponder - + private var showAZButtonProperties: ButtonProperties { - return ButtonProperties( + ButtonProperties( icon: .aToZ, label: NSLocalizedString("A to Z", comment: "Show A-Z short button title"), accessibilityLabel: PlaySRGAccessibilityLocalizedString("A to Z shows", comment: "Show A-Z button label") ) } - + private var showByDateButtonProperties: ButtonProperties { switch style { case .calendar: - return ButtonProperties( + ButtonProperties( icon: .calendar, label: NSLocalizedString("By date", comment: "Show by date short button title"), accessibilityLabel: PlaySRGAccessibilityLocalizedString("Shows by date", comment: "Show by date button label") ) case .programGuide: - return ButtonProperties( + ButtonProperties( icon: .tvGuide, label: NSLocalizedString("TV guide", comment: "TV guide short button title") ) } } - + var body: some View { HStack { ExpandingButton(icon: showAZButtonProperties.icon, label: showAZButtonProperties.label, accessibilityLabel: showAZButtonProperties.accessibilityLabel) { @@ -70,12 +70,12 @@ extension ShowAccessCell { case calendar case programGuide } - + private struct ButtonProperties { let icon: ImageResource let label: String let accessibilityLabel: String? - + init(icon: ImageResource, label: String, accessibilityLabel: String? = nil) { self.icon = icon self.label = label @@ -88,7 +88,7 @@ extension ShowAccessCell { enum ShowAccessCellSize { static func fullWidth() -> NSCollectionLayoutSize { - return NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(38)) + NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(38)) } } @@ -96,7 +96,7 @@ enum ShowAccessCellSize { struct ShowAccessCell_Previews: PreviewProvider { private static let size = ShowAccessCellSize.fullWidth().previewSize - + static var previews: some View { Group { ShowAccessCell(style: .calendar) diff --git a/Application/Sources/UI/Views/ShowButton.swift b/Application/Sources/UI/Views/ShowButton.swift index a78d2c332..101ad3c3f 100644 --- a/Application/Sources/UI/Views/ShowButton.swift +++ b/Application/Sources/UI/Views/ShowButton.swift @@ -14,23 +14,23 @@ struct ShowButton: View { private let show: SRGShow private let isFavorite: Bool private let action: () -> Void - + @State private var isFocused = false - + init(show: SRGShow, isFavorite: Bool, action: @escaping () -> Void) { self.show = show self.isFavorite = isFavorite self.action = action } - + private var favoriteIcon: ImageResource { - return isFavorite ? .favoriteFull : .favorite + isFavorite ? .favoriteFull : .favorite } - + private var accessibilityLabel: String { - return "\(show.title), \(PlaySRGAccessibilityLocalizedString("More episodes", comment: "Button to access more episodes"))" + "\(show.title), \(PlaySRGAccessibilityLocalizedString("More episodes", comment: "Button to access more episodes"))" } - + var body: some View { Button(action: action) { HStack(spacing: 8) { diff --git a/Application/Sources/UI/Views/ShowCell.swift b/Application/Sources/UI/Views/ShowCell.swift index 06f497807..cbbc1bccd 100644 --- a/Application/Sources/UI/Views/ShowCell.swift +++ b/Application/Sources/UI/Views/ShowCell.swift @@ -15,58 +15,58 @@ struct ShowCell: View, PrimaryColorSettable { case standard case favorite } - + @Binding private(set) var show: SRGShow? - + let style: Style let imageVariant: SRGImageVariant - - internal var primaryColor: Color = .srgGrayD2 - + + var primaryColor: Color = .srgGrayD2 + @StateObject private var model = ShowCellViewModel() - + @Environment(\.isEditing) private var isEditing @Environment(\.isSelected) private var isSelected - + init(show: SRGShow?, style: Style, imageVariant: SRGImageVariant) { _show = .constant(show) self.style = style self.imageVariant = imageVariant } - + var body: some View { Group { -#if os(tvOS) - LabeledCardButton(aspectRatio: ShowCellSize.aspectRatio(for: imageVariant), action: action) { - ShowVisualView(show: model.show, size: .small, imageVariant: imageVariant) - .unredactable() - .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint, traits: .isButton) - } label: { - if imageVariant != .poster { - DescriptionView(model: model, style: style) - .primaryColor(primaryColor) - .frame(maxHeight: .infinity, alignment: .top) - .padding(.top, ShowCellSize.verticalPadding) + #if os(tvOS) + LabeledCardButton(aspectRatio: ShowCellSize.aspectRatio(for: imageVariant), action: action) { + ShowVisualView(show: model.show, size: .small, imageVariant: imageVariant) + .unredactable() + .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint, traits: .isButton) + } label: { + if imageVariant != .poster { + DescriptionView(model: model, style: style) + .primaryColor(primaryColor) + .frame(maxHeight: .infinity, alignment: .top) + .padding(.top, ShowCellSize.verticalPadding) + } } - } -#else - VStack(spacing: 0) { - ShowVisualView(show: model.show, size: .small, imageVariant: imageVariant) - .aspectRatio(ShowCellSize.aspectRatio(for: imageVariant), contentMode: .fit) - if imageVariant != .poster { - DescriptionView(model: model, style: style) - .primaryColor(primaryColor) - .padding(.horizontal, ShowCellSize.horizontalPadding) - .padding(.vertical, ShowCellSize.verticalPadding) + #else + VStack(spacing: 0) { + ShowVisualView(show: model.show, size: .small, imageVariant: imageVariant) + .aspectRatio(ShowCellSize.aspectRatio(for: imageVariant), contentMode: .fit) + if imageVariant == .default { + DescriptionView(model: model, style: style) + .primaryColor(primaryColor) + .padding(.horizontal, ShowCellSize.horizontalPadding) + .padding(.vertical, ShowCellSize.verticalPadding) + } } - } - .background(Color.srgGray23) - .redactable() - .selectionAppearance(when: isSelected && show != nil, while: isEditing) - .cornerRadius(LayoutStandardViewCornerRadius) - .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint, traits: accessibilityTraits) - .frame(maxHeight: .infinity, alignment: .top) -#endif + .background(Color.srgGray23) + .redactable() + .selectionAppearance(when: isSelected && show != nil, while: isEditing) + .cornerRadius(LayoutStandardViewCornerRadius) + .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint, traits: accessibilityTraits) + .frame(maxHeight: .infinity, alignment: .top) + #endif } .redactedIfNil(show) .onAppear { @@ -76,36 +76,36 @@ struct ShowCell: View, PrimaryColorSettable { model.show = newValue } } - -#if os(tvOS) - private func action() { - if let show { - navigateToShow(show) + + #if os(tvOS) + private func action() { + if let show { + navigateToShow(show) + } } - } -#endif - + #endif + /// Behavior: h-exp, v-hug private struct DescriptionView: View, PrimaryColorSettable { @ObservedObject var model: ShowCellViewModel let style: Style - - internal var primaryColor: Color = .srgGrayD2 - + + var primaryColor: Color = .srgGrayD2 + var body: some View { HStack { Text(model.title ?? "") .srgFont(.H4) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .topLeading) -#if os(iOS) - if style == .favorite, model.isSubscribed { - Image(.subscriptionFull) - .resizable() - .scaledToFit() - .frame(height: 12) - } -#endif + #if os(iOS) + if style == .favorite, model.isSubscribed { + Image(.subscriptionFull) + .resizable() + .scaledToFit() + .frame(height: 12) + } + #endif } .foregroundColor(primaryColor) } @@ -116,15 +116,15 @@ struct ShowCell: View, PrimaryColorSettable { private extension ShowCell { var accessibilityLabel: String? { - return model.title + model.title } - + var accessibilityHint: String? { - return !isEditing ? PlaySRGAccessibilityLocalizedString("Opens show details.", comment: "Show cell hint") : PlaySRGAccessibilityLocalizedString("Toggles selection.", comment: "Show cell hint in edit mode") + !isEditing ? PlaySRGAccessibilityLocalizedString("Opens show details.", comment: "Show cell hint") : PlaySRGAccessibilityLocalizedString("Toggles selection.", comment: "Show cell hint in edit mode") } - + var accessibilityTraits: AccessibilityTraits { - return isSelected ? .isSelected : [] + isSelected ? .isSelected : [] } } @@ -133,25 +133,32 @@ private extension ShowCell { enum ShowCellSize { fileprivate static let horizontalPadding: CGFloat = constant(iOS: 10, tvOS: 0) fileprivate static let verticalPadding: CGFloat = constant(iOS: 5, tvOS: 7) - + private static func heightOffset(for imageVariant: SRGImageVariant) -> CGFloat { - return imageVariant != .poster ? constant(iOS: 32, tvOS: 45) : 0 + imageVariant == .default ? constant(iOS: 32, tvOS: 45) : 0 } - + fileprivate static func aspectRatio(for imageVariant: SRGImageVariant) -> CGFloat { - return imageVariant != .poster ? 16 / 9 : 2 / 3 + switch imageVariant { + case .poster: + 2 / 3 + case .podcast: + 1 + case .default: + 16 / 9 + } } - + fileprivate static func itemWidth(for imageVariant: SRGImageVariant) -> CGFloat { - return imageVariant != .poster ? constant(iOS: 210, tvOS: 375) : constant(iOS: 158, tvOS: 276) + imageVariant != .poster ? constant(iOS: 210, tvOS: 375) : constant(iOS: 158, tvOS: 276) } - + static func swimlane(for imageVariant: SRGImageVariant) -> NSCollectionLayoutSize { - return LayoutSwimlaneCellSize(itemWidth(for: imageVariant), aspectRatio(for: imageVariant), heightOffset(for: imageVariant)) + LayoutSwimlaneCellSize(itemWidth(for: imageVariant), aspectRatio(for: imageVariant), heightOffset(for: imageVariant)) } - + static func grid(for imageVariant: SRGImageVariant, layoutWidth: CGFloat, spacing: CGFloat) -> NSCollectionLayoutSize { - return LayoutGridCellSize(itemWidth(for: imageVariant), aspectRatio(for: imageVariant), heightOffset(for: imageVariant), layoutWidth, spacing, 2) + LayoutGridCellSize(itemWidth(for: imageVariant), aspectRatio(for: imageVariant), heightOffset(for: imageVariant), layoutWidth, spacing, 2) } } @@ -160,11 +167,14 @@ enum ShowCellSize { struct ShowCell_Previews: PreviewProvider { private static let defaultSize = ShowCellSize.swimlane(for: .default).previewSize private static let posterSize = ShowCellSize.swimlane(for: .poster).previewSize - + private static let podcastSize = ShowCellSize.swimlane(for: .podcast).previewSize + static var previews: some View { ShowCell(show: Mock.show(.standard), style: .standard, imageVariant: .default) .previewLayout(.fixed(width: defaultSize.width, height: defaultSize.height)) ShowCell(show: Mock.show(.standard), style: .standard, imageVariant: .poster) .previewLayout(.fixed(width: posterSize.width, height: posterSize.height)) + ShowCell(show: Mock.show(.standard), style: .standard, imageVariant: .podcast) + .previewLayout(.fixed(width: podcastSize.width, height: podcastSize.height)) } } diff --git a/Application/Sources/UI/Views/ShowCellViewModel.swift b/Application/Sources/UI/Views/ShowCellViewModel.swift index d4511814b..5d5a4a372 100644 --- a/Application/Sources/UI/Views/ShowCellViewModel.swift +++ b/Application/Sources/UI/Views/ShowCellViewModel.swift @@ -11,24 +11,24 @@ import Combine class ShowCellViewModel: ObservableObject { @Published var show: SRGShow? @Published private(set) var isSubscribed = false - + init() { -#if os(iOS) - // Drop initial value; a relevant value is first assigned when the view appears - $show - .dropFirst() - .map { show in - guard let show else { - return Just(false).eraseToAnyPublisher() + #if os(iOS) + // Drop initial value; a relevant value is first assigned when the view appears + $show + .dropFirst() + .map { show in + guard let show else { + return Just(false).eraseToAnyPublisher() + } + return UserDataPublishers.subscriptionStatusPublisher(for: show) + .map { $0 == .subscribed } + .eraseToAnyPublisher() } - return UserDataPublishers.subscriptionStatusPublisher(for: show) - .map { $0 == .subscribed } - .eraseToAnyPublisher() - } - .switchToLatest() - .receive(on: DispatchQueue.main) - .assign(to: &$isSubscribed) -#endif + .switchToLatest() + .receive(on: DispatchQueue.main) + .assign(to: &$isSubscribed) + #endif } } @@ -36,6 +36,6 @@ class ShowCellViewModel: ObservableObject { extension ShowCellViewModel { var title: String? { - return show?.title + show?.title } } diff --git a/Application/Sources/UI/Views/ShowVisualView.swift b/Application/Sources/UI/Views/ShowVisualView.swift index e7720bd60..e59bc0997 100644 --- a/Application/Sources/UI/Views/ShowVisualView.swift +++ b/Application/Sources/UI/Views/ShowVisualView.swift @@ -14,7 +14,7 @@ struct ShowVisualView: View { let size: SRGImageSize let imageVariant: SRGImageVariant let contentMode: ImageView.ContentMode - + init( show: SRGShow?, size: SRGImageSize, @@ -26,14 +26,21 @@ struct ShowVisualView: View { self.imageVariant = imageVariant self.contentMode = contentMode } - + var body: some View { ImageView(source: imageUrl, contentMode: contentMode) .background(Color.thumbnailBackground) } - + private var imageUrl: URL? { - return imageVariant == .poster ? url(for: show?.posterImage, size: size) : url(for: show?.image, size: size) + switch imageVariant { + case .poster: + url(for: show?.posterImage, size: size) + case .podcast: + url(for: show?.podcastImage, size: size) + case .default: + url(for: show?.image, size: size) + } } } diff --git a/Application/Sources/UI/Views/SimpleButton.swift b/Application/Sources/UI/Views/SimpleButton.swift index 0605b0fcd..e6de9412a 100644 --- a/Application/Sources/UI/Views/SimpleButton.swift +++ b/Application/Sources/UI/Views/SimpleButton.swift @@ -17,21 +17,21 @@ struct SimpleButton: View, PrimaryColorSettable, PrimaryFocusedColorSettable { private let accessibilityLabel: String private let accessibilityHint: String? private let action: () -> Void - - internal var primaryColor: Color = .srgGrayD2 - internal var primaryFocusedColor: Color = .srgGray16 - + + var primaryColor: Color = .srgGrayD2 + var primaryFocusedColor: Color = .srgGray16 + @State private var isFocused = false - + init(icon: ImageResource, accessibilityLabel: String, accessibilityHint: String? = nil, action: @escaping () -> Void) { self.icon = icon - self.label = nil - self.labelMinimumScaleFactor = nil + label = nil + labelMinimumScaleFactor = nil self.accessibilityLabel = accessibilityLabel self.accessibilityHint = accessibilityHint self.action = action } - + init(icon: ImageResource, label: String, labelMinimumScaleFactor: CGFloat = 0.8, accessibilityLabel: String? = nil, accessibilityHint: String? = nil, action: @escaping () -> Void) { self.icon = icon self.label = label @@ -40,7 +40,7 @@ struct SimpleButton: View, PrimaryColorSettable, PrimaryFocusedColorSettable { self.accessibilityHint = accessibilityHint self.action = action } - + var body: some View { Button(action: action) { HStack(spacing: 8) { diff --git a/Application/Sources/UI/Views/Stack.swift b/Application/Sources/UI/Views/Stack.swift index f6426b4d7..94c7b078c 100644 --- a/Application/Sources/UI/Views/Stack.swift +++ b/Application/Sources/UI/Views/Stack.swift @@ -16,43 +16,42 @@ struct Stack: View { let direction: StackDirection let alignment: StackAlignment let spacing: CGFloat? - + @Binding private var content: () -> Content - + init(direction: StackDirection, alignment: StackAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: @escaping () -> Content) { self.direction = direction self.alignment = alignment self.spacing = spacing _content = .constant(content) } - + private static func horizontalAlignment(for alignment: StackAlignment) -> HorizontalAlignment { switch alignment { case .leading: - return .leading + .leading case .center: - return .center + .center case .trailing: - return .trailing + .trailing } } - + private static func verticalAlignment(for alignment: StackAlignment) -> VerticalAlignment { switch alignment { case .leading: - return .top + .top case .center: - return .center + .center case .trailing: - return .bottom + .bottom } } - + var body: some View { if direction == .horizontal { HStack(alignment: Self.verticalAlignment(for: alignment), spacing: spacing, content: content) - } - else { + } else { VStack(alignment: Self.horizontalAlignment(for: alignment), spacing: spacing, content: content) } } diff --git a/Application/Sources/UI/Views/TableLoadMoreFooterView.swift b/Application/Sources/UI/Views/TableLoadMoreFooterView.swift index a8720cb33..245bdcda0 100644 --- a/Application/Sources/UI/Views/TableLoadMoreFooterView.swift +++ b/Application/Sources/UI/Views/TableLoadMoreFooterView.swift @@ -4,25 +4,25 @@ // License information is available from the LICENSE file. // -import UIKit import SRGAppearance +import UIKit class TableLoadMoreFooterView: UIView { override init(frame: CGRect) { super.init(frame: frame) - + backgroundColor = .clear - + let loadingImageView = UIImageView.play_loadingImageView(withTintColor: .srgGrayD2) addSubview(loadingImageView) - + loadingImageView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ loadingImageView.centerXAnchor.constraint(equalTo: centerXAnchor), loadingImageView.centerYAnchor.constraint(equalTo: centerYAnchor) ]) } - + required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) } diff --git a/Application/Sources/UI/Views/TableView.swift b/Application/Sources/UI/Views/TableView.swift index e1a5dbd22..e7283162b 100644 --- a/Application/Sources/UI/Views/TableView.swift +++ b/Application/Sources/UI/Views/TableView.swift @@ -12,18 +12,18 @@ import UIKit super.init(frame: frame, style: style) Self.tableViewConfigure(self) } - + required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) Self.tableViewConfigure(self) } - + // Apply standard Play configuration to a given table view (with manual cell height). @objc static func tableViewConfigure(_ tableView: UITableView) { tableView.backgroundColor = .clear tableView.indicatorStyle = .white tableView.separatorStyle = .none - + // Avoid unreliable content size calculations when row heights are specified (leads to glitches during scrolling or // reloads). We do not use automatic cell sizing, so this is best avoided by default. This was the old default behavior, // but newer versions of Xcode now enable automatic sizing by default. diff --git a/Application/Sources/UI/Views/TitleHeaderView.swift b/Application/Sources/UI/Views/TitleHeaderView.swift index 3e2a1fb46..253063500 100644 --- a/Application/Sources/UI/Views/TitleHeaderView.swift +++ b/Application/Sources/UI/Views/TitleHeaderView.swift @@ -14,16 +14,16 @@ struct TitleHeaderView: View, PrimaryColorSettable { let description: String? let titleTextAlignment: TextAlignment let topPadding: CGFloat - + init(_ title: String?, description: String? = nil, titleTextAlignment: TextAlignment = .leading, topPadding: CGFloat = 0) { self.title = title self.description = description self.titleTextAlignment = titleTextAlignment self.topPadding = topPadding } - - internal var primaryColor: Color = .white - + + var primaryColor: Color = .white + var body: some View { VStack(alignment: .leading, spacing: 16) { if let title { @@ -34,9 +34,9 @@ struct TitleHeaderView: View, PrimaryColorSettable { Text(title) .srgFont(.H1) .foregroundColor(primaryColor) - // Fix sizing issue, see https://swiftui-lab.com/bug-linelimit-ignored/. The size is correct - // when calculated with a `UIHostingController`, but without this the text does not occupy - // all lines it could. + // Fix sizing issue, see https://swiftui-lab.com/bug-linelimit-ignored/. The size is correct + // when calculated with a `UIHostingController`, but without this the text does not occupy + // all lines it could. .fixedSize(horizontal: false, vertical: true) .multilineTextAlignment(titleTextAlignment) if titleTextAlignment != .trailing { @@ -47,9 +47,9 @@ struct TitleHeaderView: View, PrimaryColorSettable { Text(description) .srgFont(.body) .foregroundColor(primaryColor) - // Fix sizing issue, see https://swiftui-lab.com/bug-linelimit-ignored/. The size is correct - // when calculated with a `UIHostingController`, but without this the text does not occupy - // all lines it could. + // Fix sizing issue, see https://swiftui-lab.com/bug-linelimit-ignored/. The size is correct + // when calculated with a `UIHostingController`, but without this the text does not occupy + // all lines it could. .fixedSize(horizontal: false, vertical: true) .multilineTextAlignment(.leading) } @@ -70,8 +70,7 @@ enum TitleHeaderViewSize { let fittingSize = CGSize(width: layoutWidth, height: UIView.layoutFittingExpandedSize.height) let size = TitleHeaderView(title, description: description, topPadding: topPadding).adaptiveSizeThatFits(in: fittingSize, for: horizontalSizeClass) return NSCollectionLayoutSize(widthDimension: .absolute(size.width), heightDimension: .absolute(size.height)) - } - else { + } else { return NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(LayoutHeaderHeightZero)) } } @@ -93,7 +92,7 @@ struct TitleHeaderView_Previews: PreviewProvider { .previewLayout(.sizeThatFits) .frame(width: 1000) .environment(\.horizontalSizeClass, .regular) - + Group { TitleHeaderView("Title", description: "description") TitleHeaderView(.loremIpsum, description: .loremIpsum) diff --git a/Application/Sources/UI/Views/TopicCell.swift b/Application/Sources/UI/Views/TopicCell.swift index 144c84d9d..792c4a53d 100644 --- a/Application/Sources/UI/Views/TopicCell.swift +++ b/Application/Sources/UI/Views/TopicCell.swift @@ -12,44 +12,44 @@ import SwiftUI struct TopicCell: View { let topic: SRGTopic? - + @Environment(\.isSelected) private var isSelected - + var body: some View { Group { -#if os(tvOS) - ExpandingCardButton(action: action) { + #if os(tvOS) + ExpandingCardButton(action: action) { + MainView(topic: topic) + .unredactable() + .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint, traits: .isButton) + } + #else MainView(topic: topic) - .unredactable() - .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint, traits: .isButton) - } -#else - MainView(topic: topic) - .redactable() - .selectionAppearance(when: isSelected && topic != nil) - .cornerRadius(LayoutStandardViewCornerRadius) - .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint) -#endif + .redactable() + .selectionAppearance(when: isSelected && topic != nil) + .cornerRadius(LayoutStandardViewCornerRadius) + .accessibilityElement(label: accessibilityLabel, hint: accessibilityHint) + #endif } .redactedIfNil(topic) } - -#if os(tvOS) - private func action() { - if let topic { - navigateToTopic(topic) + + #if os(tvOS) + private func action() { + if let topic { + navigateToTopic(topic) + } } - } -#endif - + #endif + /// Behavior: h-exp, v-exp private struct MainView: View { let topic: SRGTopic? - + private var imageUrl: URL? { - return url(for: topic?.image, size: .small) + url(for: topic?.image, size: .small) } - + var body: some View { ZStack { ImageView(source: imageUrl) @@ -72,11 +72,11 @@ struct TopicCell: View { private extension TopicCell { var accessibilityLabel: String? { - return topic?.title + topic?.title } - + var accessibilityHint: String? { - return PlaySRGAccessibilityLocalizedString("Opens topic details.", comment: "Show cell hint") + PlaySRGAccessibilityLocalizedString("Opens topic details.", comment: "Show cell hint") } } @@ -84,15 +84,15 @@ private extension TopicCell { enum TopicCellSize { fileprivate static let aspectRatio: CGFloat = 16 / 9 - + private static let defaultItemWidth: CGFloat = constant(iOS: 150, tvOS: 300) - + static func swimlane(itemWidth: CGFloat = defaultItemWidth) -> NSCollectionLayoutSize { - return LayoutSwimlaneCellSize(itemWidth, aspectRatio, 0) + LayoutSwimlaneCellSize(itemWidth, aspectRatio, 0) } - + static func grid(layoutWidth: CGFloat, spacing: CGFloat) -> NSCollectionLayoutSize { - return LayoutGridCellSize(defaultItemWidth, aspectRatio, 0, layoutWidth, spacing, 2) + LayoutGridCellSize(defaultItemWidth, aspectRatio, 0, layoutWidth, spacing, 2) } } @@ -100,7 +100,7 @@ enum TopicCellSize { struct TopicCell_Previews: PreviewProvider { private static let size = TopicCellSize.swimlane().previewSize - + static var previews: some View { TopicCell(topic: Mock.topic()) .previewLayout(.fixed(width: size.width, height: size.height)) diff --git a/Application/Sources/UI/Views/TopicGradientView.swift b/Application/Sources/UI/Views/TopicGradientView.swift index 5b414e699..bbe3d1eb2 100644 --- a/Application/Sources/UI/Views/TopicGradientView.swift +++ b/Application/Sources/UI/Views/TopicGradientView.swift @@ -11,18 +11,18 @@ import SwiftUI /// Behavior: h-exp, v-exp struct TopicGradientView: View { enum Style { - case topicPage - case showPage + case topicPage + case showPage } - + let topic: SRGTopic let style: Style - + init(_ topic: SRGTopic, style: Style) { self.topic = topic self.style = style } - + var body: some View { if let topicColors = ApplicationConfiguration.shared.topicColors(for: topic) { ZStack { @@ -36,12 +36,12 @@ struct TopicGradientView: View { Color.clear } } - + /// Behavior: h-exp, v-exp private struct RadialColorGradient: View { let topicColors: (Color, Color) let opacity: Double - + var body: some View { GeometryReader { geometry in RadialGradient( @@ -56,7 +56,7 @@ struct TopicGradientView: View { } } } - + /// Behavior: h-exp, v-exp private struct LinearGreyGradient: View { var body: some View { @@ -67,13 +67,13 @@ struct TopicGradientView: View { ) } } - + private var opacity: Double { switch style { case .topicPage: - return 0.6 + 0.6 case .showPage: - return 0.4 + 0.4 } } } @@ -83,7 +83,7 @@ struct TopicGradientView: View { struct TopicGradientView_Previews: PreviewProvider { private struct PreviewView: View { @ViewBuilder var content: () -> Content - + var body: some View { ZStack { Rectangle() @@ -92,7 +92,7 @@ struct TopicGradientView_Previews: PreviewProvider { } } } - + static var previews: some View { Group { PreviewView { @@ -106,7 +106,7 @@ struct TopicGradientView_Previews: PreviewProvider { } } .previewLayout(.fixed(width: 400, height: 572)) - + Group { PreviewView { TopicGradientView(Mock.topic(), style: .topicPage) diff --git a/Application/Sources/UI/Views/TransluscentHeaderView.swift b/Application/Sources/UI/Views/TransluscentHeaderView.swift index dc811eebf..dccbc5c9b 100644 --- a/Application/Sources/UI/Views/TransluscentHeaderView.swift +++ b/Application/Sources/UI/Views/TransluscentHeaderView.swift @@ -12,7 +12,7 @@ import SwiftUI struct TransluscentHeaderView: View { let title: String let horizontalPadding: CGFloat - + var body: some View { Text(title) .srgFont(.H3) @@ -30,18 +30,17 @@ struct TransluscentHeaderView: View { private extension View { func transluscentBackground() -> some View { -#if os(iOS) - Group { - if #available(iOS 15, *) { - background(.thinMaterial) - } - else { - background(Blur(style: .systemThinMaterial)) + #if os(iOS) + Group { + if #available(iOS 15, *) { + background(.thinMaterial) + } else { + background(Blur(style: .systemThinMaterial)) + } } - } -#else - return background(Color.clear) -#endif + #else + return background(Color.clear) + #endif } } @@ -53,8 +52,7 @@ enum TransluscentHeaderViewSize { let hostController = UIHostingController(rootView: TransluscentHeaderView(title: title, horizontalPadding: horizontalPadding)) let size = hostController.sizeThatFits(in: CGSize(width: layoutWidth, height: UIView.layoutFittingExpandedSize.height)) return NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(size.height)) - } - else { + } else { return NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(LayoutHeaderHeightZero)) } } diff --git a/Application/Sources/UI/Views/TruncatableTextView.swift b/Application/Sources/UI/Views/TruncatableTextView.swift index 5abb95328..008a4ddea 100644 --- a/Application/Sources/UI/Views/TruncatableTextView.swift +++ b/Application/Sources/UI/Views/TruncatableTextView.swift @@ -19,23 +19,23 @@ import SwiftUI struct TruncatableTextView: View, PrimaryColorSettable, SecondaryColorSettable { let content: String let lineLimit: Int? - + let showMore: () -> Void - - internal var primaryColor: Color = .srgGrayD2 - internal var secondaryColor: Color = .white - + + var primaryColor: Color = .srgGrayD2 + var secondaryColor: Color = .white + @State private var isTruncated = false @State private var isFocused = false - + init(content: String, lineLimit: Int?, showMore: @escaping () -> Void) { // Compact the content to not have "show more" button floating alone at bottom right. self.content = content.compacted - + self.lineLimit = lineLimit self.showMore = showMore } - + var body: some View { Button { showMore() @@ -45,12 +45,12 @@ struct TruncatableTextView: View, PrimaryColorSettable, SecondaryColorSettable { } .onParentFocusChange { isFocused = $0 } } -#if os(tvOS) + #if os(tvOS) .buttonStyle(TextButtonStyle(focused: isFocused)) -#endif + #endif .disabled(!isTruncated) } - + /// Behavior: h-exp, v-hug fileprivate struct MainView: View { let content: String @@ -58,24 +58,24 @@ struct TruncatableTextView: View, PrimaryColorSettable, SecondaryColorSettable { let primaryColor: Color let secondaryColor: Color @Binding private(set) var isTruncated: Bool - + let showMore: () -> Void - + @State private var intrinsicSize: CGSize = .zero @State private var truncatedSize: CGSize = .zero - + private let fontStyle: SRGFont.Style = .body private let showMoreButtonString = NSLocalizedString("More", comment: "More label on truncatable text view") private let showMoreButtonStringAccessibilityLabel = PlaySRGAccessibilityLocalizedString("More", comment: "More label on truncatable text view") - + private func text(lineLimit: Int?) -> some View { - return Text(content) + Text(content) .srgFont(fontStyle) .lineLimit(lineLimit) .foregroundColor(primaryColor) .multilineTextAlignment(.leading) } - + var body: some View { HStack(spacing: 0) { ZStack(alignment: .bottomTrailing) { @@ -95,7 +95,7 @@ struct TruncatableTextView: View, PrimaryColorSettable, SecondaryColorSettable { } } ) - + if isTruncated { Text(showMoreButtonString) .srgFont(fontStyle) @@ -117,20 +117,20 @@ struct TruncatableTextView: View, PrimaryColorSettable, SecondaryColorSettable { Spacer(minLength: 0) } } - + /// Behavior: h-exp, v-hug struct BottomMask: View { let fontStyle: SRGFont.Style let showMoreButtonString: String - + // Content size changes are tracked to update mask. @Environment(\.sizeCategory) private var sizeCategory - + var body: some View { HStack(alignment: .bottom, spacing: 0) { Rectangle() .foregroundColor(.black) - + LinearGradient( gradient: Gradient(stops: [ Gradient.Stop(color: .black, location: 0), @@ -140,7 +140,7 @@ struct TruncatableTextView: View, PrimaryColorSettable, SecondaryColorSettable { endPoint: .trailing ) .frame(width: constant(iOS: 32, tvOS: 60), height: showMoreButtonString.heightOfString(usingFontStyle: fontStyle)) - + Rectangle() .foregroundColor(.clear) .frame(width: showMoreButtonString.widthOfString(usingFontStyle: fontStyle), alignment: .center) diff --git a/Application/Sources/UI/Views/WebView.swift b/Application/Sources/UI/Views/WebView.swift index 1fe41d8f0..ef2769230 100644 --- a/Application/Sources/UI/Views/WebView.swift +++ b/Application/Sources/UI/Views/WebView.swift @@ -13,18 +13,18 @@ struct WebView: UIViewControllerRepresentable { let request: URLRequest let customization: ((WKWebView) -> Void)? let decisionHandler: ((URL) -> WKNavigationActionPolicy)? - + init(request: URLRequest, customization: ((WKWebView) -> Void)? = nil, decisionHandler: ((URL) -> WKNavigationActionPolicy)? = nil) { self.request = request self.customization = customization self.decisionHandler = decisionHandler } - - func makeUIViewController(context: Context) -> WebViewController { - return WebViewController(request: request, customizationBlock: customization, decisionHandler: decisionHandler) + + func makeUIViewController(context _: Context) -> WebViewController { + WebViewController(request: request, customizationBlock: customization, decisionHandler: decisionHandler) } - - func updateUIViewController(_ uiViewController: WebViewController, context: Context) { + + func updateUIViewController(_: WebViewController, context _: Context) { // Never updated } } diff --git a/Common/Sources/Helpers/AccessibilityIdentifier.swift b/Common/Sources/Helpers/AccessibilityIdentifier.swift index 688ccbc2f..d071706c1 100644 --- a/Common/Sources/Helpers/AccessibilityIdentifier.swift +++ b/Common/Sources/Helpers/AccessibilityIdentifier.swift @@ -14,25 +14,25 @@ import Foundation case searchTabBarItem case profileTabBarItem case closeButton - + var value: String { switch self { case .videosTabBarItem: - return "videosTabBarItem" + "videosTabBarItem" case .audiosTabBarItem: - return "audiosTabBarItem" + "audiosTabBarItem" case .livestreamsTabBarItem: - return "livestreamsTabBarItem" + "livestreamsTabBarItem" case .tvGuideTabBarItem: - return "tvGuideTabBarItem" + "tvGuideTabBarItem" case .showsTabBarItem: - return "showsTabBarItem" + "showsTabBarItem" case .searchTabBarItem: - return "searchTabBarItem" + "searchTabBarItem" case .profileTabBarItem: - return "profileTabBarItem" + "profileTabBarItem" case .closeButton: - return "closeButton" + "closeButton" } } } @@ -42,15 +42,15 @@ import Foundation */ @objc class AccessibilityIdentifierObjC: NSObject { private let identifier: AccessibilityIdentifier - + @objc class func identifier(_ identifier: AccessibilityIdentifier) -> AccessibilityIdentifierObjC { - return Self(identifier: identifier) + Self(identifier: identifier) } - + @objc var value: String { - return identifier.value + identifier.value } - + required init(identifier: AccessibilityIdentifier) { self.identifier = identifier } diff --git a/Extensions/NotificationService/Sources/NotificationService.m b/Extensions/NotificationService/Sources/NotificationService.m index e48b14518..6ff0fc144 100755 --- a/Extensions/NotificationService/Sources/NotificationService.m +++ b/Extensions/NotificationService/Sources/NotificationService.m @@ -44,14 +44,14 @@ - (instancetype)init - (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler { UNNotificationContent *notificationContent = request.content; - + // Keep references for expiration method implementation self.notificationContent = notificationContent; self.contentHandler = contentHandler; - + UserNotification *notification = [[UserNotification alloc] initWithRequest:request]; [UserNotification saveNotification:notification read:NO]; - + self.downloadTask = [self imageDownloadTaskForNotification:notification content:notificationContent withContentHandler:contentHandler]; [self.downloadTask resume]; } @@ -69,38 +69,38 @@ - (NSURLSessionDownloadTask *)imageDownloadTaskForNotification:(UserNotification withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler { NSParameterAssert(contentHandler); - + NSURL *scaledImageURL = [self.dataProvider URLForImage:notification.image withSize:SRGImageSizeMedium]; if (! scaledImageURL) { contentHandler(content); return nil; } - + return [[NSURLSession sharedSession] downloadTaskWithURL:scaledImageURL completionHandler:^(NSURL *temporaryFileURL, NSURLResponse *response, NSError *error) { if (error) { contentHandler(content); return; } - + NSString *MIMEType = response.MIMEType; if (! MIMEType) { contentHandler(content); return; } - + NSString *UTI = NotificationServiceUTIFromMIMEType(MIMEType); if (! UTI) { contentHandler(content); return; } - + NSDictionary *options = @{ UNNotificationAttachmentOptionsTypeHintKey : UTI }; UNNotificationAttachment *attachment = [UNNotificationAttachment attachmentWithIdentifier:@"" URL:temporaryFileURL options:options error:NULL]; if (! attachment) { contentHandler(content); return; } - + UNMutableNotificationContent *mutableContent = content.mutableCopy; mutableContent.attachments = @[attachment]; contentHandler(mutableContent.copy); diff --git a/Extensions/TopShelf/Sources/ContentProvider.swift b/Extensions/TopShelf/Sources/ContentProvider.swift index 354e1472f..6401a0d43 100644 --- a/Extensions/TopShelf/Sources/ContentProvider.swift +++ b/Extensions/TopShelf/Sources/ContentProvider.swift @@ -14,13 +14,11 @@ final class ContentProvider: TVTopShelfContentProvider { let width1x: SRGImageWidth let width2x: SRGImageWidth } - - static let dataProvider: SRGDataProvider = { - return SRGDataProvider(serviceURL: SRGIntegrationLayerProductionServiceURL()) - }() - + + static let dataProvider: SRGDataProvider = .init(serviceURL: SRGIntegrationLayerProductionServiceURL()) + private var cancellable: AnyCancellable? - + private static let vendor: SRGVendor = { let businessUnit = Bundle.main.infoDictionary?["PlaySRGBusinessUnit"] as! String switch businessUnit { @@ -39,30 +37,27 @@ final class ContentProvider: TVTopShelfContentProvider { return .SRF } }() - - private static let urlScheme: String = { - return Bundle.main.infoDictionary?["PlaySRGURLScheme"] as! String - }() - + + private static let urlScheme: String = Bundle.main.infoDictionary?["PlaySRGURLScheme"] as! String + private static func imageLayout(for show: SRGShow) -> ImageLayout { let imageLayout = Bundle.main.infoDictionary?["PlaySRGImageLayout"] as! String switch imageLayout { case "poster": if let posterImage = show.posterImage { return ImageLayout(image: posterImage, shape: .poster, width1x: .width240, width2x: .width480) - } - else { + } else { return ImageLayout(image: show.image, shape: .hdtv, width1x: .width480, width2x: .width960) } default: return ImageLayout(image: show.image, shape: .hdtv, width1x: .width480, width2x: .width960) } } - + private static func url(for image: SRGImage?, width: SRGImageWidth) -> URL? { - return dataProvider.url(for: image, width: width) + dataProvider.url(for: image, width: width) } - + private static func contentPublisher() -> AnyPublisher<[SRGShow], Error> { let contentRequest = Bundle.main.infoDictionary?["PlaySRGContentRequest"] as! String switch contentRequest { @@ -77,11 +72,11 @@ final class ContentProvider: TVTopShelfContentProvider { .eraseToAnyPublisher() } } - + private static func item(from show: SRGShow) -> TVTopShelfSectionedItem { let item = TVTopShelfSectionedItem(identifier: show.urn) item.title = show.title - + let imageLayout = Self.imageLayout(for: show) item.imageShape = imageLayout.shape item.setImageURL(url(for: imageLayout.image, width: imageLayout.width1x), for: .screenScale1x) @@ -89,21 +84,21 @@ final class ContentProvider: TVTopShelfContentProvider { item.displayAction = TVTopShelfAction(url: URL(string: "\(urlScheme)://show/\(show.urn)")!) return item } - + private static func content(from shows: [SRGShow]) -> TVTopShelfSectionedContent { let items = shows.map { item(from: $0) } let section = TVTopShelfItemCollection(items: items) section.title = NSLocalizedString("Popular on Play SRG", comment: "Most poular shows on Play SRG, displayed in the tvOS top shelf") return TVTopShelfSectionedContent(sections: [section]) } - + private static func contentPublisher() -> AnyPublisher { - return contentPublisher() + contentPublisher() .map { Optional(content(from: $0)) } .replaceError(with: nil) .eraseToAnyPublisher() } - + override func loadTopShelfContent(completionHandler: @escaping (TVTopShelfContent?) -> Void) { cancellable = Self.contentPublisher() .sink { content in diff --git a/PlaySRG.xcodeproj/project.pbxproj b/PlaySRG.xcodeproj/project.pbxproj index c2f8bd893..bb91bf918 100644 --- a/PlaySRG.xcodeproj/project.pbxproj +++ b/PlaySRG.xcodeproj/project.pbxproj @@ -112,6 +112,16 @@ 042F6F6729E0AE6E003F46AA /* UIImage+PlaySRG.swift in Sources */ = {isa = PBXBuildFile; fileRef = 042F6F5F29E0AE6E003F46AA /* UIImage+PlaySRG.swift */; }; 042F6F6829E0AE6E003F46AA /* UIImage+PlaySRG.swift in Sources */ = {isa = PBXBuildFile; fileRef = 042F6F5F29E0AE6E003F46AA /* UIImage+PlaySRG.swift */; }; 042F6F6929E0AE6E003F46AA /* UIImage+PlaySRG.swift in Sources */ = {isa = PBXBuildFile; fileRef = 042F6F5F29E0AE6E003F46AA /* UIImage+PlaySRG.swift */; }; + 04308F5E2B9DB2FF00A11CC7 /* SquareImages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04308F5D2B9DB2FE00A11CC7 /* SquareImages.swift */; }; + 04308F5F2B9DB2FF00A11CC7 /* SquareImages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04308F5D2B9DB2FE00A11CC7 /* SquareImages.swift */; }; + 04308F602B9DB2FF00A11CC7 /* SquareImages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04308F5D2B9DB2FE00A11CC7 /* SquareImages.swift */; }; + 04308F612B9DB2FF00A11CC7 /* SquareImages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04308F5D2B9DB2FE00A11CC7 /* SquareImages.swift */; }; + 04308F622B9DB2FF00A11CC7 /* SquareImages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04308F5D2B9DB2FE00A11CC7 /* SquareImages.swift */; }; + 04308F632B9DB2FF00A11CC7 /* SquareImages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04308F5D2B9DB2FE00A11CC7 /* SquareImages.swift */; }; + 04308F642B9DB2FF00A11CC7 /* SquareImages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04308F5D2B9DB2FE00A11CC7 /* SquareImages.swift */; }; + 04308F652B9DB2FF00A11CC7 /* SquareImages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04308F5D2B9DB2FE00A11CC7 /* SquareImages.swift */; }; + 04308F662B9DB2FF00A11CC7 /* SquareImages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04308F5D2B9DB2FE00A11CC7 /* SquareImages.swift */; }; + 04308F672B9DB2FF00A11CC7 /* SquareImages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04308F5D2B9DB2FE00A11CC7 /* SquareImages.swift */; }; 04395F1B2B1BB72400F6A634 /* TableLoadMoreFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04395F1A2B1BB72400F6A634 /* TableLoadMoreFooterView.swift */; }; 04395F1C2B1BB72400F6A634 /* TableLoadMoreFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04395F1A2B1BB72400F6A634 /* TableLoadMoreFooterView.swift */; }; 04395F1D2B1BB72400F6A634 /* TableLoadMoreFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04395F1A2B1BB72400F6A634 /* TableLoadMoreFooterView.swift */; }; @@ -217,16 +227,6 @@ 0463DA172A73D8B000CD6556 /* ProgramAndChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0463DA0F2A73D8B000CD6556 /* ProgramAndChannel.swift */; }; 0463DA182A73D8B000CD6556 /* ProgramAndChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0463DA0F2A73D8B000CD6556 /* ProgramAndChannel.swift */; }; 0463DA192A73D8B000CD6556 /* ProgramAndChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0463DA0F2A73D8B000CD6556 /* ProgramAndChannel.swift */; }; - 046845A62BF56A13003A0073 /* ColorsSettable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046845A52BF56A13003A0073 /* ColorsSettable.swift */; }; - 046845A72BF56A13003A0073 /* ColorsSettable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046845A52BF56A13003A0073 /* ColorsSettable.swift */; }; - 046845A82BF56A13003A0073 /* ColorsSettable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046845A52BF56A13003A0073 /* ColorsSettable.swift */; }; - 046845A92BF56A13003A0073 /* ColorsSettable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046845A52BF56A13003A0073 /* ColorsSettable.swift */; }; - 046845AA2BF56A13003A0073 /* ColorsSettable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046845A52BF56A13003A0073 /* ColorsSettable.swift */; }; - 046845AB2BF56A13003A0073 /* ColorsSettable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046845A52BF56A13003A0073 /* ColorsSettable.swift */; }; - 046845AC2BF56A13003A0073 /* ColorsSettable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046845A52BF56A13003A0073 /* ColorsSettable.swift */; }; - 046845AD2BF56A13003A0073 /* ColorsSettable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046845A52BF56A13003A0073 /* ColorsSettable.swift */; }; - 046845AE2BF56A13003A0073 /* ColorsSettable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046845A52BF56A13003A0073 /* ColorsSettable.swift */; }; - 046845AF2BF56A13003A0073 /* ColorsSettable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046845A52BF56A13003A0073 /* ColorsSettable.swift */; }; 0468459B2BF513E2003A0073 /* ShowVisualView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0468459A2BF513E2003A0073 /* ShowVisualView.swift */; }; 0468459C2BF513E2003A0073 /* ShowVisualView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0468459A2BF513E2003A0073 /* ShowVisualView.swift */; }; 0468459D2BF513E2003A0073 /* ShowVisualView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0468459A2BF513E2003A0073 /* ShowVisualView.swift */; }; @@ -237,6 +237,16 @@ 046845A22BF513E2003A0073 /* ShowVisualView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0468459A2BF513E2003A0073 /* ShowVisualView.swift */; }; 046845A32BF513E2003A0073 /* ShowVisualView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0468459A2BF513E2003A0073 /* ShowVisualView.swift */; }; 046845A42BF513E2003A0073 /* ShowVisualView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0468459A2BF513E2003A0073 /* ShowVisualView.swift */; }; + 046845A62BF56A13003A0073 /* ColorsSettable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046845A52BF56A13003A0073 /* ColorsSettable.swift */; }; + 046845A72BF56A13003A0073 /* ColorsSettable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046845A52BF56A13003A0073 /* ColorsSettable.swift */; }; + 046845A82BF56A13003A0073 /* ColorsSettable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046845A52BF56A13003A0073 /* ColorsSettable.swift */; }; + 046845A92BF56A13003A0073 /* ColorsSettable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046845A52BF56A13003A0073 /* ColorsSettable.swift */; }; + 046845AA2BF56A13003A0073 /* ColorsSettable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046845A52BF56A13003A0073 /* ColorsSettable.swift */; }; + 046845AB2BF56A13003A0073 /* ColorsSettable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046845A52BF56A13003A0073 /* ColorsSettable.swift */; }; + 046845AC2BF56A13003A0073 /* ColorsSettable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046845A52BF56A13003A0073 /* ColorsSettable.swift */; }; + 046845AD2BF56A13003A0073 /* ColorsSettable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046845A52BF56A13003A0073 /* ColorsSettable.swift */; }; + 046845AE2BF56A13003A0073 /* ColorsSettable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046845A52BF56A13003A0073 /* ColorsSettable.swift */; }; + 046845AF2BF56A13003A0073 /* ColorsSettable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046845A52BF56A13003A0073 /* ColorsSettable.swift */; }; 046F8DC02B778E5300A71091 /* RadioChannelsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046F8DBF2B778E5300A71091 /* RadioChannelsViewController.swift */; }; 046F8DC12B778E5300A71091 /* RadioChannelsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046F8DBF2B778E5300A71091 /* RadioChannelsViewController.swift */; }; 046F8DC22B778E5300A71091 /* RadioChannelsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046F8DBF2B778E5300A71091 /* RadioChannelsViewController.swift */; }; @@ -347,6 +357,16 @@ 0490BA002A3789F500B6FB7B /* UserConsentHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0490B9F82A3789F500B6FB7B /* UserConsentHelper.swift */; }; 0490BA012A3789F500B6FB7B /* UserConsentHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0490B9F82A3789F500B6FB7B /* UserConsentHelper.swift */; }; 0490BA022A3789F500B6FB7B /* UserConsentHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0490B9F82A3789F500B6FB7B /* UserConsentHelper.swift */; }; + 04B675C72BE65C4B00F2A934 /* AudioHomepageOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B675C62BE65C4B00F2A934 /* AudioHomepageOption.swift */; }; + 04B675C82BE65C4B00F2A934 /* AudioHomepageOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B675C62BE65C4B00F2A934 /* AudioHomepageOption.swift */; }; + 04B675C92BE65C4B00F2A934 /* AudioHomepageOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B675C62BE65C4B00F2A934 /* AudioHomepageOption.swift */; }; + 04B675CA2BE65C4B00F2A934 /* AudioHomepageOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B675C62BE65C4B00F2A934 /* AudioHomepageOption.swift */; }; + 04B675CB2BE65C4B00F2A934 /* AudioHomepageOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B675C62BE65C4B00F2A934 /* AudioHomepageOption.swift */; }; + 04B675CC2BE65C4B00F2A934 /* AudioHomepageOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B675C62BE65C4B00F2A934 /* AudioHomepageOption.swift */; }; + 04B675CD2BE65C4B00F2A934 /* AudioHomepageOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B675C62BE65C4B00F2A934 /* AudioHomepageOption.swift */; }; + 04B675CE2BE65C4B00F2A934 /* AudioHomepageOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B675C62BE65C4B00F2A934 /* AudioHomepageOption.swift */; }; + 04B675CF2BE65C4B00F2A934 /* AudioHomepageOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B675C62BE65C4B00F2A934 /* AudioHomepageOption.swift */; }; + 04B675D02BE65C4B00F2A934 /* AudioHomepageOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B675C62BE65C4B00F2A934 /* AudioHomepageOption.swift */; }; 04C2DE782937C67000E85A03 /* AnalyticsClickEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04C2DE772937C67000E85A03 /* AnalyticsClickEvent.swift */; }; 04C2DE792937C67000E85A03 /* AnalyticsClickEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04C2DE772937C67000E85A03 /* AnalyticsClickEvent.swift */; }; 04C2DE7A2937C67000E85A03 /* AnalyticsClickEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04C2DE772937C67000E85A03 /* AnalyticsClickEvent.swift */; }; @@ -2514,6 +2534,16 @@ 6FFFB9BB252CA310004E40AE /* MediaDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FFFB9B6252CA310004E40AE /* MediaDetailViewModel.swift */; }; 70D8747D1FCCB2C6B309BC0B /* libPods-Play SRG-iOS-Play RTS.a in Frameworks */ = {isa = PBXBuildFile; fileRef = AB348106F5307D86C18CA361 /* libPods-Play SRG-iOS-Play RTS.a */; }; 8C3643978953564D3546A15C /* libPods-Play SRG-tvOS-Play SRF TV.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D81BAFCE19BF2C2B612FE3A2 /* libPods-Play SRG-tvOS-Play SRF TV.a */; }; + 9984F77A2C075A94009F6CC8 /* TabContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9984F7792C075A94009F6CC8 /* TabContainerViewController.swift */; }; + 9984F77B2C075A94009F6CC8 /* TabContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9984F7792C075A94009F6CC8 /* TabContainerViewController.swift */; }; + 9984F77C2C075A94009F6CC8 /* TabContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9984F7792C075A94009F6CC8 /* TabContainerViewController.swift */; }; + 9984F77D2C075A94009F6CC8 /* TabContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9984F7792C075A94009F6CC8 /* TabContainerViewController.swift */; }; + 9984F77E2C075A94009F6CC8 /* TabContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9984F7792C075A94009F6CC8 /* TabContainerViewController.swift */; }; + 99FE19272BFF49C9000B5602 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = 99FE19262BFF49C9000B5602 /* Tabman */; }; + 99FE19292BFF49F7000B5602 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = 99FE19282BFF49F7000B5602 /* Tabman */; }; + 99FE192B2BFF49FD000B5602 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = 99FE192A2BFF49FD000B5602 /* Tabman */; }; + 99FE192D2BFF4A04000B5602 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = 99FE192C2BFF4A04000B5602 /* Tabman */; }; + 99FE192F2BFF4A0B000B5602 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = 99FE192E2BFF4A0B000B5602 /* Tabman */; }; 9EA78A2C26D66E89004DAC33 /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EA78A2B26D66E89004DAC33 /* CarPlaySceneDelegate.swift */; }; 9EA78A2D26D66E89004DAC33 /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EA78A2B26D66E89004DAC33 /* CarPlaySceneDelegate.swift */; }; 9EA78A2E26D66E89004DAC33 /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EA78A2B26D66E89004DAC33 /* CarPlaySceneDelegate.swift */; }; @@ -2823,6 +2853,7 @@ 042F6F4929E09805003F46AA /* DateFormatter+playSRG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DateFormatter+playSRG.swift"; sourceTree = ""; }; 042F6F5429E0A764003F46AA /* UIColor+PlaySRG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+PlaySRG.swift"; sourceTree = ""; }; 042F6F5F29E0AE6E003F46AA /* UIImage+PlaySRG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+PlaySRG.swift"; sourceTree = ""; }; + 04308F5D2B9DB2FE00A11CC7 /* SquareImages.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SquareImages.swift; sourceTree = ""; }; 04395F1A2B1BB72400F6A634 /* TableLoadMoreFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableLoadMoreFooterView.swift; sourceTree = ""; }; 04395F202B1BBA6600F6A634 /* RefreshControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshControl.swift; sourceTree = ""; }; 04395F262B1BC44200F6A634 /* StoreReview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreReview.swift; sourceTree = ""; }; @@ -2839,8 +2870,8 @@ 045F8A032BA5A001005DDCEE /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 045F8A0E2BA5A8A5005DDCEE /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 0463DA0F2A73D8B000CD6556 /* ProgramAndChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramAndChannel.swift; sourceTree = ""; }; - 046845A52BF56A13003A0073 /* ColorsSettable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorsSettable.swift; sourceTree = ""; }; 0468459A2BF513E2003A0073 /* ShowVisualView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowVisualView.swift; sourceTree = ""; }; + 046845A52BF56A13003A0073 /* ColorsSettable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorsSettable.swift; sourceTree = ""; }; 046F8DBF2B778E5300A71091 /* RadioChannelsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioChannelsViewController.swift; sourceTree = ""; }; 046F8DC52B779E9B00A71091 /* PageContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageContainerViewController.swift; sourceTree = ""; }; 046F8DCB2B77D5F700A71091 /* UIVisualEffectView+PlaySRG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIVisualEffectView+PlaySRG.swift"; sourceTree = ""; }; @@ -2856,6 +2887,7 @@ 048FD24C2A14CF3B00929AE5 /* ProfileCellModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileCellModel.swift; sourceTree = ""; }; 0490B9F82A3789F500B6FB7B /* UserConsentHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserConsentHelper.swift; sourceTree = ""; }; 0497D5FE29D4B626005BF060 /* REMOTE_CONFIGURATION.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; name = REMOTE_CONFIGURATION.md; path = docs/REMOTE_CONFIGURATION.md; sourceTree = ""; }; + 04B675C62BE65C4B00F2A934 /* AudioHomepageOption.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioHomepageOption.swift; sourceTree = ""; }; 04C2DE772937C67000E85A03 /* AnalyticsClickEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsClickEvent.swift; sourceTree = ""; }; 04D21DBB299BEB42009CEA15 /* TruncatableTextView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TruncatableTextView.swift; sourceTree = ""; }; 04D2A92A29ACFE5900E11B28 /* Handle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Handle.swift; sourceTree = ""; }; @@ -3423,6 +3455,7 @@ 881AC8571743B65FBCF1192E /* Pods-Play SRG-iOS-Play RTR.appstore.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Play SRG-iOS-Play RTR.appstore.xcconfig"; path = "Pods/Target Support Files/Pods-Play SRG-iOS-Play RTR/Pods-Play SRG-iOS-Play RTR.appstore.xcconfig"; sourceTree = ""; }; 92B4B46D0768088FD67B3EFC /* Pods-Play SRG-iOS-Play RTR.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Play SRG-iOS-Play RTR.beta.xcconfig"; path = "Pods/Target Support Files/Pods-Play SRG-iOS-Play RTR/Pods-Play SRG-iOS-Play RTR.beta.xcconfig"; sourceTree = ""; }; 95C2A04103A3A3F3A9B9291C /* Pods-Play SRG-iOS-Play SWI.nightly.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Play SRG-iOS-Play SWI.nightly.xcconfig"; path = "Pods/Target Support Files/Pods-Play SRG-iOS-Play SWI/Pods-Play SRG-iOS-Play SWI.nightly.xcconfig"; sourceTree = ""; }; + 9984F7792C075A94009F6CC8 /* TabContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabContainerViewController.swift; sourceTree = ""; }; 9EA78A2B26D66E89004DAC33 /* CarPlaySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlaySceneDelegate.swift; sourceTree = ""; }; A660CE9179B6F1E81374096F /* Pods-Play SRG-tvOS-Play SWI TV.nightly.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Play SRG-tvOS-Play SWI TV.nightly.xcconfig"; path = "Pods/Target Support Files/Pods-Play SRG-tvOS-Play SWI TV/Pods-Play SRG-tvOS-Play SWI TV.nightly.xcconfig"; sourceTree = ""; }; A7E130D069D1EEFCAE9360BC /* Pods-Play SRG-tvOS-Play RTR TV.appstore.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Play SRG-tvOS-Play RTR TV.appstore.xcconfig"; path = "Pods/Target Support Files/Pods-Play SRG-tvOS-Play RTR TV/Pods-Play SRG-tvOS-Play RTR TV.appstore.xcconfig"; sourceTree = ""; }; @@ -3520,6 +3553,7 @@ 6F2545B52521C14E00F9F4FE /* SRGLetterbox in Frameworks */, 08F5DAF5262D99D400F717D0 /* SRGLoggerSwift in Frameworks */, 6F2345D1283BAB3600A9089D /* FSCalendar in Frameworks */, + 99FE19272BFF49C9000B5602 /* Tabman in Frameworks */, 6FD46CBB25DAB76D00874EAF /* AppCenterDistribute in Frameworks */, 6F2546C12521CFD400F9F4FE /* SRGAnalyticsIdentity in Frameworks */, 0414BF212A685B73004543BD /* SwiftUIIntrospect in Frameworks */, @@ -3553,6 +3587,7 @@ 6F2545B92521C15E00F9F4FE /* SRGUserData in Frameworks */, 08F5DAF3262D99CB00F717D0 /* SRGLoggerSwift in Frameworks */, 6F2345D3283BAB5000A9089D /* FSCalendar in Frameworks */, + 99FE19292BFF49F7000B5602 /* Tabman in Frameworks */, 6FD46CD325DAB7BA00874EAF /* AppCenterDistribute in Frameworks */, 6F2546C32521CFDC00F9F4FE /* SRGAnalyticsIdentity in Frameworks */, 0414BF232A685B81004543BD /* SwiftUIIntrospect in Frameworks */, @@ -3586,6 +3621,7 @@ 6F2545BD2521C16800F9F4FE /* SRGUserData in Frameworks */, 08F5DAF1262D99BF00F717D0 /* SRGLoggerSwift in Frameworks */, 6F2345D5283BAB5600A9089D /* FSCalendar in Frameworks */, + 99FE192B2BFF49FD000B5602 /* Tabman in Frameworks */, 6FD46CEB25DAB7C300874EAF /* AppCenterDistribute in Frameworks */, 6F2546C52521CFEA00F9F4FE /* SRGAnalyticsIdentity in Frameworks */, 0414BF252A685B8E004543BD /* SwiftUIIntrospect in Frameworks */, @@ -3619,6 +3655,7 @@ 6F2545C12521C17000F9F4FE /* SRGUserData in Frameworks */, 08F5DAEF262D99B400F717D0 /* SRGLoggerSwift in Frameworks */, 6F2345D7283BAB5B00A9089D /* FSCalendar in Frameworks */, + 99FE192D2BFF4A04000B5602 /* Tabman in Frameworks */, 6FD46D0325DAB80300874EAF /* AppCenterDistribute in Frameworks */, 6F2546C72521CFF000F9F4FE /* SRGAnalyticsIdentity in Frameworks */, 0414BF272A685B98004543BD /* SwiftUIIntrospect in Frameworks */, @@ -3652,6 +3689,7 @@ 6F2545C52521C17700F9F4FE /* SRGUserData in Frameworks */, 08F5DAED262D99A900F717D0 /* SRGLoggerSwift in Frameworks */, 6F2345D9283BAB6000A9089D /* FSCalendar in Frameworks */, + 99FE192F2BFF4A0B000B5602 /* Tabman in Frameworks */, 6FD46D1B25DAB80C00874EAF /* AppCenterDistribute in Frameworks */, 6F2546C92521CFFA00F9F4FE /* SRGAnalyticsIdentity in Frameworks */, 0414BF292A685BAC004543BD /* SwiftUIIntrospect in Frameworks */, @@ -3944,12 +3982,14 @@ 6FDF6FFF2682022C0004437E /* ApplicationSettings+Common.m */, 6FB03F5A25DECB3A0033132B /* ApplicationSettingsConstants.h */, 6FB03F5B25DECB3A0033132B /* ApplicationSettingsConstants.m */, + 04B675C62BE65C4B00F2A934 /* AudioHomepageOption.swift */, 6F5B4D5D2833F8F3004F5BA3 /* FeaturesView.swift */, 6F6C7AD32820578900BC3EA5 /* PosterImages.swift */, 6FE25909285E66570080548A /* Service.swift */, 6F8CBD802832A2CB000C7A93 /* SettingsNavigationView.swift */, 6F46B7BA281FB1FB00D20748 /* SettingsView.swift */, 6F46B7B4281FB1F100D20748 /* SettingsViewModel.swift */, + 04308F5D2B9DB2FE00A11CC7 /* SquareImages.swift */, 6F73C639271DB5AE00DBDBFB /* UserDefaults+ApplicationSettings.swift */, 6F6C7AC82820576000BC3EA5 /* UserLocation.swift */, 6FDAAD8D2832824E008E2806 /* WhatsNewView.swift */, @@ -4780,6 +4820,7 @@ 082910AD239E90D200D168F4 /* TabBarController.m */, 6F80E9C021A682E60027CA2F /* TableRequestViewController.h */, 6F80E9C121A682E60027CA2F /* TableRequestViewController.m */, + 9984F7792C075A94009F6CC8 /* TabContainerViewController.swift */, ); path = Controllers; sourceTree = ""; @@ -5390,7 +5431,7 @@ 08B836951FBDC07100D849D8 /* Restore original icons if needed */, 0806E7A71D4BA4ED002ED406 /* Make custom icons */, 08C68D851D38D6F400BB8AAA /* Sources */, - 08AD6C6E266BDC5000FAF1FA /* Swift Lint */, + 08AD6C6E266BDC5000FAF1FA /* Swift Lint and Swift Format */, 6FE3C7C21F74EC5C00D57D29 /* Update License Information */, 08C68D861D38D6F400BB8AAA /* Frameworks */, 08C68D871D38D6F400BB8AAA /* Resources */, @@ -5434,6 +5475,7 @@ 04030F4D2A34BD1100BC573B /* Usercentrics */, 04030F502A34BD3A00BC573B /* UsercentricsUI */, 04FA73A72AA62E5C0014E7F7 /* GoogleCastSDK-no-bluetooth */, + 99FE19262BFF49C9000B5602 /* Tabman */, ); productName = "Play SRF"; productReference = 08C68D891D38D6F400BB8AAA /* Play SRF.app */; @@ -5447,7 +5489,7 @@ 08B836941FBDBEAB00D849D8 /* Restore original icons if needed */, 0806E7A61D4BA4DF002ED406 /* Make custom icons */, 08C68DBC1D38D70D00BB8AAA /* Sources */, - 08A1C3BF2636E77900276A5E /* Swift Lint */, + 08A1C3BF2636E77900276A5E /* Swift Lint and Swift Format */, 6FE3C7C31F74EEA500D57D29 /* Update License Information */, 08C68DBD1D38D70D00BB8AAA /* Frameworks */, 08C68DBE1D38D70D00BB8AAA /* Resources */, @@ -5490,6 +5532,7 @@ 04030F522A34BD6D00BC573B /* Usercentrics */, 04030F542A34BD6D00BC573B /* UsercentricsUI */, 04FA73A92AA62E640014E7F7 /* GoogleCastSDK-no-bluetooth */, + 99FE19282BFF49F7000B5602 /* Tabman */, ); productName = "Play RTS"; productReference = 08C68DC01D38D70D00BB8AAA /* Play RTS.app */; @@ -5503,7 +5546,7 @@ 08B836961FBDC08400D849D8 /* Restore original icons if needed */, 0806E7A51D4BA4CF002ED406 /* Make custom icons */, 08C68DF31D38D72000BB8AAA /* Sources */, - 08AD6C6F266BDC6D00FAF1FA /* Swift Lint */, + 08AD6C6F266BDC6D00FAF1FA /* Swift Lint and Swift Format */, 6FE3C7C41F74EEBE00D57D29 /* Update License Information */, 08C68DF41D38D72000BB8AAA /* Frameworks */, 08C68DF51D38D72000BB8AAA /* Resources */, @@ -5546,6 +5589,7 @@ 04030F562A34BD7700BC573B /* Usercentrics */, 04030F582A34BD7700BC573B /* UsercentricsUI */, 04FA73A52AA62E4B0014E7F7 /* GoogleCastSDK-no-bluetooth */, + 99FE192A2BFF49FD000B5602 /* Tabman */, ); productName = "Play RSI"; productReference = 08C68DF71D38D72000BB8AAA /* Play RSI.app */; @@ -5559,7 +5603,7 @@ 08B836971FBDC09500D849D8 /* Restore original icons if needed */, 0806E7A41D4BA4BA002ED406 /* Make custom icons */, 08C68E2A1D38D73000BB8AAA /* Sources */, - 08AD6C70266BDC7B00FAF1FA /* Swift Lint */, + 08AD6C70266BDC7B00FAF1FA /* Swift Lint and Swift Format */, 6FE3C7C61F74EECF00D57D29 /* Update License Information */, 08C68E2B1D38D73000BB8AAA /* Frameworks */, 08C68E2C1D38D73000BB8AAA /* Resources */, @@ -5602,6 +5646,7 @@ 04030F5A2A34BD8300BC573B /* Usercentrics */, 04030F5C2A34BD8300BC573B /* UsercentricsUI */, 04FA73AB2AA62E760014E7F7 /* GoogleCastSDK-no-bluetooth */, + 99FE192C2BFF4A04000B5602 /* Tabman */, ); productName = "Play RTR"; productReference = 08C68E2E1D38D73000BB8AAA /* Play RTR.app */; @@ -5615,7 +5660,7 @@ 08B836981FBDC0A100D849D8 /* Restore original icons if needed */, 0806E7A31D4BA494002ED406 /* Make custom icons */, 08C68E611D38D73C00BB8AAA /* Sources */, - 08AD6C71266BDC8D00FAF1FA /* Swift Lint */, + 08AD6C71266BDC8D00FAF1FA /* Swift Lint and Swift Format */, 6FE3C7C71F74EEDB00D57D29 /* Update License Information */, 08C68E621D38D73C00BB8AAA /* Frameworks */, 08C68E631D38D73C00BB8AAA /* Resources */, @@ -5658,6 +5703,7 @@ 04030F5E2A34BD8B00BC573B /* Usercentrics */, 04030F602A34BD8B00BC573B /* UsercentricsUI */, 04FA73AD2AA62E7D0014E7F7 /* GoogleCastSDK-no-bluetooth */, + 99FE192E2BFF4A0B000B5602 /* Tabman */, ); productName = "Play SWI"; productReference = 08C68E651D38D73C00BB8AAA /* Play SWI.app */; @@ -5856,7 +5902,7 @@ 08C13555256B2746002F67B8 /* Restore original icons if needed */, 08C13557256B277B002F67B8 /* Make custom icons */, 6F331CE024D06B8200C096AB /* Sources */, - 08AD6C72266BDCA100FAF1FA /* Swift Lint */, + 08AD6C72266BDCA100FAF1FA /* Swift Lint and Swift Format */, 6F331CE124D06B8200C096AB /* Frameworks */, 6F331CE224D06B8200C096AB /* Resources */, 08C13558256B27AC002F67B8 /* Restore original icons */, @@ -5900,7 +5946,7 @@ 08C1356D256B28C0002F67B8 /* Restore original icons if needed */, 08C13571256B291D002F67B8 /* Make custom icons */, 6F331CF824D06BA200C096AB /* Sources */, - 08AD6C73266BDCBB00FAF1FA /* Swift Lint */, + 08AD6C73266BDCBB00FAF1FA /* Swift Lint and Swift Format */, 6F331CF924D06BA200C096AB /* Frameworks */, 6F331CFA24D06BA200C096AB /* Resources */, 08C13575256B2977002F67B8 /* Restore original icons */, @@ -5944,7 +5990,7 @@ 08C1356E256B28C7002F67B8 /* Restore original icons if needed */, 08C13572256B2922002F67B8 /* Make custom icons */, 6F331D1024D06BB800C096AB /* Sources */, - 08AD6C74266BDCC800FAF1FA /* Swift Lint */, + 08AD6C74266BDCC800FAF1FA /* Swift Lint and Swift Format */, 6F331D1124D06BB800C096AB /* Frameworks */, 6F331D1224D06BB800C096AB /* Resources */, 08C13576256B297E002F67B8 /* Restore original icons */, @@ -5988,7 +6034,7 @@ 08C1356F256B28CF002F67B8 /* Restore original icons if needed */, 08C13573256B2927002F67B8 /* Make custom icons */, 6F331D2824D06BC600C096AB /* Sources */, - 08AD6C75266BDCD400FAF1FA /* Swift Lint */, + 08AD6C75266BDCD400FAF1FA /* Swift Lint and Swift Format */, 6F331D2924D06BC600C096AB /* Frameworks */, 6F331D2A24D06BC600C096AB /* Resources */, 08C13577256B2983002F67B8 /* Restore original icons */, @@ -6032,7 +6078,7 @@ 08C13570256B28D7002F67B8 /* Restore original icons if needed */, 08C13574256B292C002F67B8 /* Make custom icons */, 6F331D4024D06C2600C096AB /* Sources */, - 08AD6C76266BDCDE00FAF1FA /* Swift Lint */, + 08AD6C76266BDCDE00FAF1FA /* Swift Lint and Swift Format */, 6F331D4124D06C2600C096AB /* Frameworks */, 6F331D4224D06C2600C096AB /* Resources */, 08C13578256B2989002F67B8 /* Restore original icons */, @@ -6400,6 +6446,7 @@ 04030F4C2A34BD1100BC573B /* XCRemoteSwiftPackageReference "usercentrics-spm-sdk" */, 04030F4F2A34BD3A00BC573B /* XCRemoteSwiftPackageReference "usercentrics-spm-ui" */, 04FA73A42AA62E4B0014E7F7 /* XCRemoteSwiftPackageReference "GoogleCastSDK-no-bluetooth" */, + 99FE19252BFF49C9000B5602 /* XCRemoteSwiftPackageReference "Tabman" */, ); productRefGroup = 08C68D501D38D49600BB8AAA /* Products */; projectDirPath = ""; @@ -6968,7 +7015,7 @@ shellPath = /bin/sh; shellScript = "\"${SRCROOT}/Scripts/generate-icons-restore.sh\"\n"; }; - 08A1C3BF2636E77900276A5E /* Swift Lint */ = { + 08A1C3BF2636E77900276A5E /* Swift Lint and Swift Format */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; @@ -6978,16 +7025,16 @@ ); inputPaths = ( ); - name = "Swift Lint"; + name = "Swift Lint and Swift Format"; outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "export PATH=\"$PATH:/opt/homebrew/bin\"\n\nif which swiftlint >/dev/null; then\n if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n swiftlint\n else\n swiftlint --strict \n fi\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; + shellScript = "export PATH=\"$PATH:/opt/homebrew/bin\"\n\nif which swiftlint >/dev/null; then\n if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n swiftlint\n else\n swiftlint --strict\n fi\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n\nif which swiftformat >/dev/null; then\n if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n swiftformat --lint --lenient .\n else\n swiftformat --lint .\n fi\nelse\n echo \"warning: swiftformat not installed, download from https://github.com/nicklockwood/SwiftFormat\"\nfi\n"; }; - 08AD6C6E266BDC5000FAF1FA /* Swift Lint */ = { + 08AD6C6E266BDC5000FAF1FA /* Swift Lint and Swift Format */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; @@ -6997,16 +7044,16 @@ ); inputPaths = ( ); - name = "Swift Lint"; + name = "Swift Lint and Swift Format"; outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "export PATH=\"$PATH:/opt/homebrew/bin\"\n\nif which swiftlint >/dev/null; then\n if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n swiftlint\n else\n swiftlint --strict \n fi\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; + shellScript = "export PATH=\"$PATH:/opt/homebrew/bin\"\n\nif which swiftlint >/dev/null; then\n if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n swiftlint\n else\n swiftlint --strict\n fi\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n\nif which swiftformat >/dev/null; then\n if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n swiftformat --lint --lenient .\n else\n swiftformat --lint .\n fi\nelse\n echo \"warning: swiftformat not installed, download from https://github.com/nicklockwood/SwiftFormat\"\nfi\n"; }; - 08AD6C6F266BDC6D00FAF1FA /* Swift Lint */ = { + 08AD6C6F266BDC6D00FAF1FA /* Swift Lint and Swift Format */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; @@ -7016,16 +7063,16 @@ ); inputPaths = ( ); - name = "Swift Lint"; + name = "Swift Lint and Swift Format"; outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "export PATH=\"$PATH:/opt/homebrew/bin\"\n\nif which swiftlint >/dev/null; then\n if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n swiftlint\n else\n swiftlint --strict \n fi\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; + shellScript = "export PATH=\"$PATH:/opt/homebrew/bin\"\n\nif which swiftlint >/dev/null; then\n if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n swiftlint\n else\n swiftlint --strict\n fi\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n\nif which swiftformat >/dev/null; then\n if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n swiftformat --lint --lenient .\n else\n swiftformat --lint .\n fi\nelse\n echo \"warning: swiftformat not installed, download from https://github.com/nicklockwood/SwiftFormat\"\nfi\n"; }; - 08AD6C70266BDC7B00FAF1FA /* Swift Lint */ = { + 08AD6C70266BDC7B00FAF1FA /* Swift Lint and Swift Format */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; @@ -7035,16 +7082,16 @@ ); inputPaths = ( ); - name = "Swift Lint"; + name = "Swift Lint and Swift Format"; outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "export PATH=\"$PATH:/opt/homebrew/bin\"\n\nif which swiftlint >/dev/null; then\n if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n swiftlint\n else\n swiftlint --strict \n fi\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; + shellScript = "export PATH=\"$PATH:/opt/homebrew/bin\"\n\nif which swiftlint >/dev/null; then\n if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n swiftlint\n else\n swiftlint --strict\n fi\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n\nif which swiftformat >/dev/null; then\n if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n swiftformat --lint --lenient .\n else\n swiftformat --lint .\n fi\nelse\n echo \"warning: swiftformat not installed, download from https://github.com/nicklockwood/SwiftFormat\"\nfi\n"; }; - 08AD6C71266BDC8D00FAF1FA /* Swift Lint */ = { + 08AD6C71266BDC8D00FAF1FA /* Swift Lint and Swift Format */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; @@ -7054,16 +7101,16 @@ ); inputPaths = ( ); - name = "Swift Lint"; + name = "Swift Lint and Swift Format"; outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "export PATH=\"$PATH:/opt/homebrew/bin\"\n\nif which swiftlint >/dev/null; then\n if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n swiftlint\n else\n swiftlint --strict \n fi\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; + shellScript = "export PATH=\"$PATH:/opt/homebrew/bin\"\n\nif which swiftlint >/dev/null; then\n if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n swiftlint\n else\n swiftlint --strict\n fi\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n\nif which swiftformat >/dev/null; then\n if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n swiftformat --lint --lenient .\n else\n swiftformat --lint .\n fi\nelse\n echo \"warning: swiftformat not installed, download from https://github.com/nicklockwood/SwiftFormat\"\nfi\n"; }; - 08AD6C72266BDCA100FAF1FA /* Swift Lint */ = { + 08AD6C72266BDCA100FAF1FA /* Swift Lint and Swift Format */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; @@ -7073,16 +7120,16 @@ ); inputPaths = ( ); - name = "Swift Lint"; + name = "Swift Lint and Swift Format"; outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "export PATH=\"$PATH:/opt/homebrew/bin\"\n\nif which swiftlint >/dev/null; then\n if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n swiftlint\n else\n swiftlint --strict \n fi\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; + shellScript = "export PATH=\"$PATH:/opt/homebrew/bin\"\n\nif which swiftlint >/dev/null; then\n if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n swiftlint\n else\n swiftlint --strict\n fi\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n\nif which swiftformat >/dev/null; then\n if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n swiftformat --lint --lenient .\n else\n swiftformat --lint .\n fi\nelse\n echo \"warning: swiftformat not installed, download from https://github.com/nicklockwood/SwiftFormat\"\nfi\n"; }; - 08AD6C73266BDCBB00FAF1FA /* Swift Lint */ = { + 08AD6C73266BDCBB00FAF1FA /* Swift Lint and Swift Format */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; @@ -7092,16 +7139,16 @@ ); inputPaths = ( ); - name = "Swift Lint"; + name = "Swift Lint and Swift Format"; outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "export PATH=\"$PATH:/opt/homebrew/bin\"\n\nif which swiftlint >/dev/null; then\n if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n swiftlint\n else\n swiftlint --strict \n fi\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; + shellScript = "export PATH=\"$PATH:/opt/homebrew/bin\"\n\nif which swiftlint >/dev/null; then\n if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n swiftlint\n else\n swiftlint --strict\n fi\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n\nif which swiftformat >/dev/null; then\n if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n swiftformat --lint --lenient .\n else\n swiftformat --lint .\n fi\nelse\n echo \"warning: swiftformat not installed, download from https://github.com/nicklockwood/SwiftFormat\"\nfi\n"; }; - 08AD6C74266BDCC800FAF1FA /* Swift Lint */ = { + 08AD6C74266BDCC800FAF1FA /* Swift Lint and Swift Format */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; @@ -7111,16 +7158,16 @@ ); inputPaths = ( ); - name = "Swift Lint"; + name = "Swift Lint and Swift Format"; outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "export PATH=\"$PATH:/opt/homebrew/bin\"\n\nif which swiftlint >/dev/null; then\n if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n swiftlint\n else\n swiftlint --strict \n fi\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; + shellScript = "export PATH=\"$PATH:/opt/homebrew/bin\"\n\nif which swiftlint >/dev/null; then\n if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n swiftlint\n else\n swiftlint --strict\n fi\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n\nif which swiftformat >/dev/null; then\n if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n swiftformat --lint --lenient .\n else\n swiftformat --lint .\n fi\nelse\n echo \"warning: swiftformat not installed, download from https://github.com/nicklockwood/SwiftFormat\"\nfi\n"; }; - 08AD6C75266BDCD400FAF1FA /* Swift Lint */ = { + 08AD6C75266BDCD400FAF1FA /* Swift Lint and Swift Format */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; @@ -7130,16 +7177,16 @@ ); inputPaths = ( ); - name = "Swift Lint"; + name = "Swift Lint and Swift Format"; outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "export PATH=\"$PATH:/opt/homebrew/bin\"\n\nif which swiftlint >/dev/null; then\n if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n swiftlint\n else\n swiftlint --strict \n fi\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; + shellScript = "export PATH=\"$PATH:/opt/homebrew/bin\"\n\nif which swiftlint >/dev/null; then\n if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n swiftlint\n else\n swiftlint --strict\n fi\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n\nif which swiftformat >/dev/null; then\n if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n swiftformat --lint --lenient .\n else\n swiftformat --lint .\n fi\nelse\n echo \"warning: swiftformat not installed, download from https://github.com/nicklockwood/SwiftFormat\"\nfi\n"; }; - 08AD6C76266BDCDE00FAF1FA /* Swift Lint */ = { + 08AD6C76266BDCDE00FAF1FA /* Swift Lint and Swift Format */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; @@ -7149,14 +7196,14 @@ ); inputPaths = ( ); - name = "Swift Lint"; + name = "Swift Lint and Swift Format"; outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "export PATH=\"$PATH:/opt/homebrew/bin\"\n\nif which swiftlint >/dev/null; then\n if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n swiftlint\n else\n swiftlint --strict \n fi\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; + shellScript = "export PATH=\"$PATH:/opt/homebrew/bin\"\n\nif which swiftlint >/dev/null; then\n if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n swiftlint\n else\n swiftlint --strict\n fi\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n\nif which swiftformat >/dev/null; then\n if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n swiftformat --lint --lenient .\n else\n swiftformat --lint .\n fi\nelse\n echo \"warning: swiftformat not installed, download from https://github.com/nicklockwood/SwiftFormat\"\nfi\n"; }; 08B4A22D2093577300474EBB /* Copy Urban Airship Configuration File */ = { isa = PBXShellScriptBuildPhase; @@ -8455,6 +8502,7 @@ 08AA551D1D49EBF600C5026E /* ApplicationConfiguration.m in Sources */, 6FB90D18270D91010033D860 /* CarPlayList.swift in Sources */, 6FDF08D7218B126700B2AF2C /* Download.m in Sources */, + 04B675C72BE65C4B00F2A934 /* AudioHomepageOption.swift in Sources */, 6FC2A21C265E3D2300EBC0F0 /* SectionShowHeaderView.swift in Sources */, 041DD1742B1B95AA00C9368A /* GradientView.swift in Sources */, 048FD24D2A14CF3B00929AE5 /* ProfileCellModel.swift in Sources */, @@ -8579,6 +8627,7 @@ 6FAE561C26C19D6F00EBFCD6 /* UICollectionView+Index.swift in Sources */, 6F30AB9C2604B66600457331 /* TopicCell.swift in Sources */, 0463DA102A73D8B000CD6556 /* ProgramAndChannel.swift in Sources */, + 04308F5E2B9DB2FF00A11CC7 /* SquareImages.swift in Sources */, 6F16C7A326025698006F685A /* PageViewController.swift in Sources */, 04D5F91F286C4542000A5A4E /* Recommendation.swift in Sources */, 6FA5D15D1F2077B10059E4E2 /* NSString+PlaySRG.m in Sources */, @@ -8592,6 +8641,7 @@ 6F33E6122860AEFF00724E76 /* Navigation.swift in Sources */, 6F8A545A2655100400AE78FD /* SectionViewController.swift in Sources */, 0458A51B2B7AC10A0007BA10 /* TitleHeaderView.swift in Sources */, + 9984F77A2C075A94009F6CC8 /* TabContainerViewController.swift in Sources */, 0451D7EE2B1CEDAD005A2150 /* Banner.swift in Sources */, 04395F272B1BC44200F6A634 /* StoreReview.swift in Sources */, 08209308208F522A00711DE4 /* PushService.m in Sources */, @@ -8664,6 +8714,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 04308F5F2B9DB2FF00A11CC7 /* SquareImages.swift in Sources */, 046F8DCD2B77D5F700A71091 /* UIVisualEffectView+PlaySRG.swift in Sources */, 6F9210E826AEDDD200291CA9 /* Environment.swift in Sources */, 6F80106A20443230009FE197 /* PlayApplication.m in Sources */, @@ -8739,6 +8790,7 @@ 6F6C7AD52820578900BC3EA5 /* PosterImages.swift in Sources */, 6F2E160A26A84ED200F3DC89 /* ProgramCell.swift in Sources */, 081220C21DD0ADAC00BF8326 /* DownloadSession.m in Sources */, + 04B675C82BE65C4B00F2A934 /* AudioHomepageOption.swift in Sources */, 04395F2E2B1BF38B00F6A634 /* GoogleCastBarButtonItem.swift in Sources */, 6F3B0222245AAE1B00C5A8D7 /* ProgramTableViewCell.m in Sources */, 6FD686202460670600B8018A /* Channel.m in Sources */, @@ -8858,6 +8910,7 @@ 085C0D9A2611F33F008E07C8 /* ShowAccessCell.swift in Sources */, 0481D5BB29F55EAE00D174B3 /* AccessibilityIdentifier.swift in Sources */, 6F49EF94263A9F2200ED96D2 /* LiveMediaCellViewModel.swift in Sources */, + 9984F77B2C075A94009F6CC8 /* TabContainerViewController.swift in Sources */, 6FDAAD95283283EB008E2806 /* WebView.swift in Sources */, 04F184CF28EC5EE500B1207C /* ShowButton.swift in Sources */, 04D21DBD299BEB42009CEA15 /* TruncatableTextView.swift in Sources */, @@ -8932,6 +8985,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 04308F602B9DB2FF00A11CC7 /* SquareImages.swift in Sources */, 046F8DCE2B77D5F700A71091 /* UIVisualEffectView+PlaySRG.swift in Sources */, 6F9210EA26AEDDD700291CA9 /* Environment.swift in Sources */, 6F80106B20443230009FE197 /* PlayApplication.m in Sources */, @@ -9007,6 +9061,7 @@ 6F6C7AD62820578900BC3EA5 /* PosterImages.swift in Sources */, 6F4843D523EC4D4D00868F78 /* GoogleCastPlaybackButton.m in Sources */, 6F2E160B26A84ED200F3DC89 /* ProgramCell.swift in Sources */, + 04B675C92BE65C4B00F2A934 /* AudioHomepageOption.swift in Sources */, 04395F2F2B1BF38B00F6A634 /* GoogleCastBarButtonItem.swift in Sources */, 081220C31DD0ADAC00BF8326 /* DownloadSession.m in Sources */, 6F3B0223245AAE1B00C5A8D7 /* ProgramTableViewCell.m in Sources */, @@ -9126,6 +9181,7 @@ 085C0D9B2611F33F008E07C8 /* ShowAccessCell.swift in Sources */, 0481D5BC29F55EAE00D174B3 /* AccessibilityIdentifier.swift in Sources */, 6F49EF95263A9F2200ED96D2 /* LiveMediaCellViewModel.swift in Sources */, + 9984F77C2C075A94009F6CC8 /* TabContainerViewController.swift in Sources */, 6FDAAD96283283EB008E2806 /* WebView.swift in Sources */, 04F184D028EC5EE500B1207C /* ShowButton.swift in Sources */, 04D21DBE299BEB42009CEA15 /* TruncatableTextView.swift in Sources */, @@ -9200,6 +9256,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 04308F612B9DB2FF00A11CC7 /* SquareImages.swift in Sources */, 046F8DCF2B77D5F700A71091 /* UIVisualEffectView+PlaySRG.swift in Sources */, 6F9210EB26AEDDD700291CA9 /* Environment.swift in Sources */, 6F80106C20443230009FE197 /* PlayApplication.m in Sources */, @@ -9275,6 +9332,7 @@ 6F6C7AD72820578900BC3EA5 /* PosterImages.swift in Sources */, 6F4843D623EC4D4D00868F78 /* GoogleCastPlaybackButton.m in Sources */, 6F2E160C26A84ED200F3DC89 /* ProgramCell.swift in Sources */, + 04B675CA2BE65C4B00F2A934 /* AudioHomepageOption.swift in Sources */, 04395F302B1BF38B00F6A634 /* GoogleCastBarButtonItem.swift in Sources */, 081220C41DD0ADAC00BF8326 /* DownloadSession.m in Sources */, 6F3B0224245AAE1B00C5A8D7 /* ProgramTableViewCell.m in Sources */, @@ -9394,6 +9452,7 @@ 085C0D9C2611F33F008E07C8 /* ShowAccessCell.swift in Sources */, 0481D5BD29F55EAE00D174B3 /* AccessibilityIdentifier.swift in Sources */, 6F49EF96263A9F2200ED96D2 /* LiveMediaCellViewModel.swift in Sources */, + 9984F77D2C075A94009F6CC8 /* TabContainerViewController.swift in Sources */, 6FDAAD97283283EB008E2806 /* WebView.swift in Sources */, 04F184D128EC5EE500B1207C /* ShowButton.swift in Sources */, 04D21DBF299BEB42009CEA15 /* TruncatableTextView.swift in Sources */, @@ -9549,6 +9608,7 @@ 0407EFEA2A509F10004A0FAB /* Bundble+PlaySRG.swift in Sources */, 6FD633E21FFBC0BE00875BE5 /* GoogleCastMiniPlayerView.m in Sources */, 6FB9B3DA26CA88AD0065092F /* TransluscentHeaderView.swift in Sources */, + 04B675CB2BE65C4B00F2A934 /* AudioHomepageOption.swift in Sources */, 6F56F9F8247C407100B2387B /* ChannelServiceSetup.m in Sources */, 04E031D328BD0EF000450D38 /* RemoteCommandCenter.swift in Sources */, 6FC0C699245FF06D00B44CAE /* ProgramHeaderView.m in Sources */, @@ -9604,6 +9664,7 @@ 6F80E9C621A682E70027CA2F /* TableRequestViewController.m in Sources */, 04395F2B2B1BC44200F6A634 /* StoreReview.swift in Sources */, 6FE1B91D1FAC34D600A58F3B /* ContentInsets.m in Sources */, + 9984F77E2C075A94009F6CC8 /* TabContainerViewController.swift in Sources */, 6F742941265BE52E0000538D /* Signals.swift in Sources */, 6F566E8424EE95CB0024B4CA /* PlayFirebaseConfiguration.m in Sources */, 0451D7F22B1CEDAD005A2150 /* Banner.swift in Sources */, @@ -9701,6 +9762,7 @@ 6FD88F8722D476CF008859EF /* UIScrollView+PlaySRG.m in Sources */, 6F091D5F270DE4FE00210713 /* Publishers.swift in Sources */, 6F9897D12412582400B390A2 /* Layout.m in Sources */, + 04308F622B9DB2FF00A11CC7 /* SquareImages.swift in Sources */, 6F16C81026025945006F685A /* PageViewModel.swift in Sources */, 6FB2C10F2719AAD5003CAAD1 /* ProgramGuideGridLayout.swift in Sources */, 04EB14C6299E312200FD004A /* SheetTextView.swift in Sources */, @@ -9848,6 +9910,7 @@ 6FDF54F728530BAC0068BABB /* SearchViewController.swift in Sources */, 0805703E2540E0FC00A59C9D /* Navigation.swift in Sources */, 6F49EF98263A9F2200ED96D2 /* LiveMediaCellViewModel.swift in Sources */, + 04308F632B9DB2FF00A11CC7 /* SquareImages.swift in Sources */, 6F0ED544252B00B000ECE97B /* LabeledButton.swift in Sources */, 6F73C63F271DB5AE00DBDBFB /* UserDefaults+ApplicationSettings.swift in Sources */, 6F30ACB22604CDFD00457331 /* ExpandingCardButton.swift in Sources */, @@ -9915,6 +9978,7 @@ 6FEF236C2732C2410098C639 /* ChannelHeaderView.swift in Sources */, 6FE86F6B2719C46C0082CAE9 /* ProgramGuideDailyViewModel.swift in Sources */, 042F6F5A29E0A764003F46AA /* UIColor+PlaySRG.swift in Sources */, + 04B675CC2BE65C4B00F2A934 /* AudioHomepageOption.swift in Sources */, 080981352622195900AA586B /* LiveMediaCell.swift in Sources */, 0858CA60271084A000EE36EA /* ProgramGuideGridViewController.swift in Sources */, 6FFF1632250A20EF0053CDA6 /* FocusTracker.swift in Sources */, @@ -10005,6 +10069,7 @@ 6FDF54F828530BAC0068BABB /* SearchViewController.swift in Sources */, 0805703F2540E0FC00A59C9D /* Navigation.swift in Sources */, 6F49EF99263A9F2200ED96D2 /* LiveMediaCellViewModel.swift in Sources */, + 04308F642B9DB2FF00A11CC7 /* SquareImages.swift in Sources */, 6F0ED545252B00B000ECE97B /* LabeledButton.swift in Sources */, 6F73C640271DB5AE00DBDBFB /* UserDefaults+ApplicationSettings.swift in Sources */, 6F30ACB32604CDFD00457331 /* ExpandingCardButton.swift in Sources */, @@ -10072,6 +10137,7 @@ 6FEF236D2732C2420098C639 /* ChannelHeaderView.swift in Sources */, 6FE86F6D2719C46F0082CAE9 /* ProgramGuideDailyViewModel.swift in Sources */, 042F6F5B29E0A764003F46AA /* UIColor+PlaySRG.swift in Sources */, + 04B675CD2BE65C4B00F2A934 /* AudioHomepageOption.swift in Sources */, 080981362622195900AA586B /* LiveMediaCell.swift in Sources */, 0858CA61271084A000EE36EA /* ProgramGuideGridViewController.swift in Sources */, 6FFF1633250A20EF0053CDA6 /* FocusTracker.swift in Sources */, @@ -10162,6 +10228,7 @@ 6FDF54F928530BAC0068BABB /* SearchViewController.swift in Sources */, 080570402540E0FC00A59C9D /* Navigation.swift in Sources */, 6F49EF9A263A9F2200ED96D2 /* LiveMediaCellViewModel.swift in Sources */, + 04308F652B9DB2FF00A11CC7 /* SquareImages.swift in Sources */, 6F0ED546252B00B000ECE97B /* LabeledButton.swift in Sources */, 6F73C641271DB5AE00DBDBFB /* UserDefaults+ApplicationSettings.swift in Sources */, 6F30ACB42604CDFD00457331 /* ExpandingCardButton.swift in Sources */, @@ -10229,6 +10296,7 @@ 6FEF236E2732C2420098C639 /* ChannelHeaderView.swift in Sources */, 6FE86F6E2719C4700082CAE9 /* ProgramGuideDailyViewModel.swift in Sources */, 042F6F5C29E0A764003F46AA /* UIColor+PlaySRG.swift in Sources */, + 04B675CE2BE65C4B00F2A934 /* AudioHomepageOption.swift in Sources */, 080981372622195900AA586B /* LiveMediaCell.swift in Sources */, 0858CA62271084A000EE36EA /* ProgramGuideGridViewController.swift in Sources */, 6FFF1634250A20EF0053CDA6 /* FocusTracker.swift in Sources */, @@ -10319,6 +10387,7 @@ 6FDF54FA28530BAC0068BABB /* SearchViewController.swift in Sources */, 080570412540E0FC00A59C9D /* Navigation.swift in Sources */, 6F49EF9B263A9F2200ED96D2 /* LiveMediaCellViewModel.swift in Sources */, + 04308F662B9DB2FF00A11CC7 /* SquareImages.swift in Sources */, 6F0ED547252B00B000ECE97B /* LabeledButton.swift in Sources */, 6F73C642271DB5AE00DBDBFB /* UserDefaults+ApplicationSettings.swift in Sources */, 6F30ACB52604CDFD00457331 /* ExpandingCardButton.swift in Sources */, @@ -10386,6 +10455,7 @@ 6FEF236F2732C2420098C639 /* ChannelHeaderView.swift in Sources */, 6FE86F6F2719C4710082CAE9 /* ProgramGuideDailyViewModel.swift in Sources */, 042F6F5D29E0A764003F46AA /* UIColor+PlaySRG.swift in Sources */, + 04B675CF2BE65C4B00F2A934 /* AudioHomepageOption.swift in Sources */, 080981382622195900AA586B /* LiveMediaCell.swift in Sources */, 0858CA63271084A000EE36EA /* ProgramGuideGridViewController.swift in Sources */, 6FFF1635250A20EF0053CDA6 /* FocusTracker.swift in Sources */, @@ -10498,6 +10568,7 @@ 6FF5D1F92746BB0400460F70 /* ProgramPreviewModel.swift in Sources */, 6F67685A281C0F7F00D61211 /* SupportInformation.swift in Sources */, 6F9ABCD92813F43000B118A3 /* ImageViewPortraitPreviews.swift in Sources */, + 04308F672B9DB2FF00A11CC7 /* SquareImages.swift in Sources */, 6FE14E69263EA83C004AD913 /* HeaderView.swift in Sources */, 6FEC8A0C261F19A300FF9762 /* ContentInsets.m in Sources */, 08F5DB19262DC7F700F717D0 /* Logger.swift in Sources */, @@ -10515,6 +10586,7 @@ 6FA8E533261CB5D2003FFDCF /* UIViewController+PlaySRG.m in Sources */, 08B971E5256EF40300195901 /* AnalyticsConstants.m in Sources */, 6F3CCEA226CAC7A2004039E2 /* Blur.swift in Sources */, + 04B675D02BE65C4B00F2A934 /* AudioHomepageOption.swift in Sources */, 08ADFB2325EE5729004662A8 /* LetterboxDelegate.swift in Sources */, 0481D5B829F460C500D174B3 /* SRGProgramComposition+PlaySRG.swift in Sources */, 0825914927285ACD00E2F8F7 /* ProgramGuideHeaderView.swift in Sources */, @@ -19375,7 +19447,7 @@ repositoryURL = "https://github.com/SRGSSR/srgdataprovider-apple.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 19.0.3; + minimumVersion = 19.0.4; }; }; 6F7269A72836CFE90072BA0B /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = { @@ -19474,6 +19546,14 @@ minimumVersion = 16.1.2; }; }; + 99FE19252BFF49C9000B5602 /* XCRemoteSwiftPackageReference "Tabman" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/uias/Tabman"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.2.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -20552,6 +20632,31 @@ package = 6FF12A46256C58A60042F446 /* XCRemoteSwiftPackageReference "srglogger-apple" */; productName = SRGLoggerSwift; }; + 99FE19262BFF49C9000B5602 /* Tabman */ = { + isa = XCSwiftPackageProductDependency; + package = 99FE19252BFF49C9000B5602 /* XCRemoteSwiftPackageReference "Tabman" */; + productName = Tabman; + }; + 99FE19282BFF49F7000B5602 /* Tabman */ = { + isa = XCSwiftPackageProductDependency; + package = 99FE19252BFF49C9000B5602 /* XCRemoteSwiftPackageReference "Tabman" */; + productName = Tabman; + }; + 99FE192A2BFF49FD000B5602 /* Tabman */ = { + isa = XCSwiftPackageProductDependency; + package = 99FE19252BFF49C9000B5602 /* XCRemoteSwiftPackageReference "Tabman" */; + productName = Tabman; + }; + 99FE192C2BFF4A04000B5602 /* Tabman */ = { + isa = XCSwiftPackageProductDependency; + package = 99FE19252BFF49C9000B5602 /* XCRemoteSwiftPackageReference "Tabman" */; + productName = Tabman; + }; + 99FE192E2BFF4A0B000B5602 /* Tabman */ = { + isa = XCSwiftPackageProductDependency; + package = 99FE19252BFF49C9000B5602 /* XCRemoteSwiftPackageReference "Tabman" */; + productName = Tabman; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 08C68D471D38D49600BB8AAA /* Project object */; diff --git a/PlaySRG.xcodeproj/xcshareddata/xcschemes/Play RSI TV screenshots.xcscheme b/PlaySRG.xcodeproj/xcshareddata/xcschemes/Play RSI TV screenshots.xcscheme index e39cb176f..bdb7b83ac 100644 --- a/PlaySRG.xcodeproj/xcshareddata/xcschemes/Play RSI TV screenshots.xcscheme +++ b/PlaySRG.xcodeproj/xcshareddata/xcschemes/Play RSI TV screenshots.xcscheme @@ -1,6 +1,6 @@ () -#if DEBUG || NIGHTLY || BETA - private var settingUpdatesCancellables = Set() -#endif - + #if DEBUG || NIGHTLY || BETA + private var settingUpdatesCancellables = Set() + #endif + private func setupAppCenter() { guard let appCenterSecret = Bundle.main.object(forInfoDictionaryKey: "AppCenterSecret") as? String, !appCenterSecret.isEmpty else { return } AppCenter.start(withAppSecret: appCenterSecret, services: [Crashes.self]) } } - + extension AppDelegate: UIApplicationDelegate { - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + func application(_ application: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { assert(NSClassFromString("ASIdentifierManager") == nil, "No implicit AdSupport.framework dependency must be found") - + PlayApplicationRunOnce({ completionHandler in PlayFirebaseConfiguration.clearCache() completionHandler(true) }, "FirebaseConfigurationReset") - + PlayApplicationRunOnce({ completionHandler in let userDefaults = UserDefaults.standard userDefaults.removeObject(forKey: PlaySRGSettingServiceIdentifier) userDefaults.synchronize() completionHandler(true) }, "DataProviderServiceURLChange") - + if Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist") != nil { FirebaseApp.configure() } - + #if !DEBUG - setupAppCenter() + setupAppCenter() #endif - + try? AVAudioSession.sharedInstance().setCategory(.playback) - + let configuration = ApplicationConfiguration.shared application.accessibilityLanguage = configuration.voiceOverLanguageCode - + if let identityWebserviceURL = configuration.identityWebserviceURL, let identityWebsiteURL = configuration.identityWebsiteURL { SRGIdentityService.current = SRGIdentityService(webserviceURL: identityWebserviceURL, websiteURL: identityWebsiteURL) - + NotificationCenter.default.weakPublisher(for: .SRGIdentityServiceUserDidCancelLogin, object: SRGIdentityService.current) .sink { _ in AnalyticsEvent.identity(action: .cancelLogin).send() } .store(in: &cancellables) - + NotificationCenter.default.weakPublisher(for: .SRGIdentityServiceUserDidLogin, object: SRGIdentityService.current) .sink { _ in AnalyticsEvent.identity(action: .login).send() } .store(in: &cancellables) - + NotificationCenter.default.weakPublisher(for: .SRGIdentityServiceUserDidLogout, object: SRGIdentityService.current) .sink { notification in let unexpectedLogout = notification.userInfo?[SRGIdentityServiceUnauthorizedKey] as? Bool ?? false @@ -81,53 +81,53 @@ extension AppDelegate: UIApplicationDelegate { } .store(in: &cancellables) } - + let cachesDirectoryUrl = URL(fileURLWithPath: NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true).first!) let storeFileUrl = cachesDirectoryUrl.appendingPathComponent("PlayData.sqlite") SRGUserData.current = SRGUserData(storeFileURL: storeFileUrl, serviceURL: configuration.userDataServiceURL, identityService: SRGIdentityService.current) - + UserConsentHelper.setup() - + let analyticsConfiguration = SRGAnalyticsConfiguration( businessUnitIdentifier: configuration.analyticsBusinessUnitIdentifier, sourceKey: configuration.analyticsSourceKey, siteName: configuration.siteName ) SRGAnalyticsTracker.shared.start(with: analyticsConfiguration, dataSource: self, identityService: SRGIdentityService.current) - -#if DEBUG || NIGHTLY || BETA - Publishers.Merge( - ApplicationSignal.settingUpdates(at: \.PlaySRGSettingServiceIdentifier), - ApplicationSignal.settingUpdates(at: \.PlaySRGSettingUserLocation) - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.updateDataProvider() - } - .store(in: &settingUpdatesCancellables) -#endif + + #if DEBUG || NIGHTLY || BETA + Publishers.Merge( + ApplicationSignal.settingUpdates(at: \.PlaySRGSettingServiceIdentifier), + ApplicationSignal.settingUpdates(at: \.PlaySRGSettingUserLocation) + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateDataProvider() + } + .store(in: &settingUpdatesCancellables) + #endif setupDataProvider() - + return true } - - func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { - return UISceneConfiguration(name: "Default", sessionRole: connectingSceneSession.role) + + func application(_: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options _: UIScene.ConnectionOptions) -> UISceneConfiguration { + UISceneConfiguration(name: "Default", sessionRole: connectingSceneSession.role) } - + private func setupDataProvider() { let dataProvider = SRGDataProvider(serviceURL: ApplicationSettingServiceURL()) -#if DEBUG || NIGHTLY || BETA - dataProvider.globalParameters = ApplicationSettingGlobalParameters() -#endif + #if DEBUG || NIGHTLY || BETA + dataProvider.globalParameters = ApplicationSettingGlobalParameters() + #endif SRGDataProvider.current = dataProvider } - + private func updateDataProvider() { URLCache.shared.removeAllCachedResponses() - + setupDataProvider() - + // Stop the current player (Picture in picture included) // TODO: For perfectly safe behavior when the service URL is changed, we should have all Letterbox // view controllers observe URL settings change and do the following in such cases. This is probably diff --git a/TV Application/Sources/ExpandingCardButton.swift b/TV Application/Sources/ExpandingCardButton.swift index d5ef66b5f..16ca9d93c 100644 --- a/TV Application/Sources/ExpandingCardButton.swift +++ b/TV Application/Sources/ExpandingCardButton.swift @@ -15,16 +15,16 @@ import SwiftUI struct ExpandingCardButton: View { private let action: () -> Void @Binding private var content: () -> Content - + fileprivate var onFocusChangeAction: ((Bool) -> Void)? - + @State private var isFocused = false - + init(action: @escaping () -> Void, @ViewBuilder content: @escaping () -> Content) { self.action = action _content = .constant(content) } - + var body: some View { GeometryReader { geometry in Button(action: action) { @@ -59,7 +59,7 @@ struct ExpandingCardButton_Previews: PreviewProvider { ExpandingCardButton(action: {}) { Color.red } - + ExpandingCardButton(action: {}) { Text("Button") .background(Color.blue) diff --git a/TV Application/Sources/LabeledButton.swift b/TV Application/Sources/LabeledButton.swift index 9cc10f8f9..8ddb883ca 100644 --- a/TV Application/Sources/LabeledButton.swift +++ b/TV Application/Sources/LabeledButton.swift @@ -12,9 +12,9 @@ struct LabeledButton: View { let accessibilityLabel: String let accessibilityHint: String? let action: () -> Void - + @State private var isFocused = false - + init(icon: ImageResource, label: String, accessibilityLabel: String? = nil, accessibilityHint: String? = nil, action: @escaping () -> Void) { self.icon = icon self.label = label @@ -22,7 +22,7 @@ struct LabeledButton: View { self.accessibilityHint = accessibilityHint self.action = action } - + var body: some View { VStack { Button(action: action) { @@ -49,7 +49,7 @@ struct LabeledButton_Previews: PreviewProvider { .previewLayout(PreviewLayout.sizeThatFits) .padding() .previewDisplayName("Short label") - + LabeledButton(icon: .favorite, label: "Watch later", action: {}) .previewLayout(PreviewLayout.sizeThatFits) .padding() diff --git a/TV Application/Sources/LabeledCardButton.swift b/TV Application/Sources/LabeledCardButton.swift index a46d88128..a3f3f5594 100644 --- a/TV Application/Sources/LabeledCardButton.swift +++ b/TV Application/Sources/LabeledCardButton.swift @@ -20,18 +20,18 @@ struct LabeledCardButton: View { private let action: () -> Void @Binding private var content: () -> Content @Binding private var label: () -> Label - + fileprivate var onFocusChangeAction: ((Bool) -> Void)? - + @State private var isFocused = false - + init(aspectRatio: CGFloat? = nil, action: @escaping () -> Void, @ViewBuilder content: @escaping () -> Content, @ViewBuilder label: @escaping () -> Label) { self.aspectRatio = aspectRatio self.action = action _content = .constant(content) _label = .constant(label) } - + var body: some View { VStack(spacing: 0) { ExpandingCardButton(action: action) { @@ -39,14 +39,14 @@ struct LabeledCardButton: View { } .onFocusChange { focused in isFocused = focused - + if let onFocusAction = onFocusChangeAction { onFocusAction(focused) } } .aspectRatio(aspectRatio, contentMode: .fit) .layoutPriority(1) - + label() .opacity(isFocused ? 1 : 0.8) .offset(x: 0, y: isFocused ? 10 : 0) @@ -67,7 +67,7 @@ extension LabeledCardButton { struct LabeledCardButton_Previews: PreviewProvider { private static let aspectRatio: CGFloat? = 16 / 9 - + static var previews: some View { Group { LabeledCardButton(aspectRatio: aspectRatio, action: {}) { @@ -75,19 +75,19 @@ struct LabeledCardButton_Previews: PreviewProvider { } label: { Color.blue } - + LabeledCardButton(aspectRatio: aspectRatio, action: {}) { Color.red } label: { Text("Label") } - + LabeledCardButton(aspectRatio: aspectRatio, action: {}) { Text("Button") } label: { Color.blue } - + LabeledCardButton(aspectRatio: aspectRatio, action: {}) { Text("Button") } label: { diff --git a/TV Application/Sources/LetterboxDelegate.swift b/TV Application/Sources/LetterboxDelegate.swift index b90e5fb15..507c7afba 100644 --- a/TV Application/Sources/LetterboxDelegate.swift +++ b/TV Application/Sources/LetterboxDelegate.swift @@ -4,40 +4,40 @@ // License information is available from the LICENSE file. // -import Foundation import Combine +import Foundation class LetterboxDelegate: NSObject { static let shared = LetterboxDelegate() - + var cancellables = Set() - + override init() { NotificationCenter.default.weakPublisher(for: .SRGLetterboxPlaybackDidContinueAutomatically) .sink { notification in guard let media = notification.userInfo?[SRGLetterboxMediaKey] as? SRGMedia else { return } - + AnalyticsEvent.continuousPlayback(action: .playAutomatic, mediaUrn: media.urn) - .send() + .send() } .store(in: &cancellables) } } extension LetterboxDelegate: SRGLetterboxViewControllerDelegate { - func letterboxViewController(_ letterboxViewController: SRGLetterboxViewController, didEngageInContinuousPlaybackWithUpcomingMedia upcomingMedia: SRGMedia) { + func letterboxViewController(_: SRGLetterboxViewController, didEngageInContinuousPlaybackWithUpcomingMedia upcomingMedia: SRGMedia) { AnalyticsEvent.continuousPlayback(action: .play, mediaUrn: upcomingMedia.urn) - .send() + .send() } - - func letterboxViewController(_ letterboxViewController: SRGLetterboxViewController, didCancelContinuousPlaybackWithUpcomingMedia upcomingMedia: SRGMedia) { + + func letterboxViewController(_: SRGLetterboxViewController, didCancelContinuousPlaybackWithUpcomingMedia upcomingMedia: SRGMedia) { AnalyticsEvent.continuousPlayback(action: .cancel, mediaUrn: upcomingMedia.urn) - .send() + .send() } - + func letterboxViewControllerDidStartPicture(inPicture letterboxViewController: SRGLetterboxViewController) { AnalyticsEvent.pictureInPicture(urn: letterboxViewController.controller.fullLengthMedia?.urn).send() } diff --git a/TV Application/Sources/MediaDetailView.swift b/TV Application/Sources/MediaDetailView.swift index edb2c4be6..ab8a9d20a 100644 --- a/TV Application/Sources/MediaDetailView.swift +++ b/TV Application/Sources/MediaDetailView.swift @@ -13,14 +13,14 @@ import SwiftUI struct MediaDetailView: View { @Binding var media: SRGMedia? @StateObject private var model = MediaDetailViewModel() - + private let playAnalyticsClickEvent: AnalyticsClickEvent? - + init(media: SRGMedia?, playAnalyticsClickEvent: AnalyticsClickEvent? = nil) { _media = .constant(media) self.playAnalyticsClickEvent = playAnalyticsClickEvent } - + var body: some View { ZStack { ImageView(source: model.imageUrl) @@ -49,11 +49,11 @@ struct MediaDetailView: View { .tracked(withTitle: analyticsPageTitle, type: AnalyticsPageType.detail.rawValue, levels: analyticsPageLevels) .redactedIfNil(media) } - + private struct DescriptionView: View { @ObservedObject var model: MediaDetailViewModel @Namespace private var namespace - + var body: some View { GeometryReader { geometry in VStack(alignment: .leading, spacing: 0) { @@ -87,25 +87,25 @@ struct MediaDetailView: View { .focusable() } } - + private struct AvailabilityView: View { @ObservedObject var model: MediaDetailViewModel - + private var availabilityInformation: String { guard let media = model.media else { return .placeholder(length: 15) } return MediaDescription.availability(for: media) } - + private var availabilityInformationAccessibilityLabel: String? { guard let media = model.media else { return nil } return MediaDescription.availabilityAccessibilityLabel(for: media) } - + private var availabilityBadgeProperties: MediaDescription.BadgeProperties? { guard let media = model.media else { return nil } return MediaDescription.availabilityBadgeProperties(for: media) } - + var body: some View { HStack(spacing: 20) { if !availabilityInformation.isEmpty { @@ -121,11 +121,11 @@ struct MediaDetailView: View { } } } - + private struct AttributeView: View { let icon: ImageResource let values: [String] - + var body: some View { HStack(spacing: 10) { Image(icon) @@ -136,10 +136,10 @@ struct MediaDetailView: View { } } } - + private struct AttributesView: View { @ObservedObject var model: MediaDetailViewModel - + var body: some View { HStack(spacing: 30) { HStack(spacing: 4) { @@ -159,10 +159,10 @@ struct MediaDetailView: View { } } } - + private struct SummaryView: View { @ObservedObject var model: MediaDetailViewModel - + var body: some View { VStack(alignment: .leading, spacing: 0) { if let summary = model.media?.play_fullSummary { @@ -174,20 +174,19 @@ struct MediaDetailView: View { } } } - + private struct ActionsView: View { @ObservedObject var model: MediaDetailViewModel - + var playButtonLabel: String { let progress = HistoryPlaybackProgressForMedia(model.media) if progress == 0 || progress == 1 { return model.media?.mediaType == .audio ? NSLocalizedString("Listen", comment: "Play button label for audio in media detail view") : NSLocalizedString("Watch", comment: "Play button label for video in media detail view") - } - else { + } else { return NSLocalizedString("Resume", comment: "Resume playback button label") } } - + var body: some View { HStack(alignment: .top, spacing: 30) { // TODO: 22 icon? @@ -202,15 +201,15 @@ struct MediaDetailView: View { let isRemoval = (model.watchLaterAllowedAction == .remove) LabeledButton(icon: isRemoval ? .watchLaterFull : .watchLater, label: isRemoval - ? NSLocalizedString("Later", comment: "Watch later or listen later button label in media detail view when a media is in the later list") - : model.media?.mediaType == .audio - ? NSLocalizedString("Listen later", comment: "Button label in media detail view to add an audio to the later list") - : NSLocalizedString("Watch later", comment: "Button label in media detail view to add a video to the later list"), + ? NSLocalizedString("Later", comment: "Watch later or listen later button label in media detail view when a media is in the later list") + : model.media?.mediaType == .audio + ? NSLocalizedString("Listen later", comment: "Button label in media detail view to add an audio to the later list") + : NSLocalizedString("Watch later", comment: "Button label in media detail view to add a video to the later list"), accessibilityLabel: isRemoval - ? PlaySRGAccessibilityLocalizedString("Delete from \"Later\" list", comment: "Media deletion from later list label in the media detail view when a media is in the later list") - : model.media?.mediaType == .audio - ? PlaySRGAccessibilityLocalizedString("Listen later", comment: "Media addition to later list label in media detail view to add an audio to the later list") - : PlaySRGAccessibilityLocalizedString("Watch later", comment: "Media addition to later list label in media detail view to add a video to the later list")) { + ? PlaySRGAccessibilityLocalizedString("Delete from \"Later\" list", comment: "Media deletion from later list label in the media detail view when a media is in the later list") + : model.media?.mediaType == .audio + ? PlaySRGAccessibilityLocalizedString("Listen later", comment: "Media addition to later list label in media detail view to add an audio to the later list") + : PlaySRGAccessibilityLocalizedString("Watch later", comment: "Media addition to later list label in media detail view to add a video to the later list")) { model.toggleWatchLater() } } @@ -222,10 +221,10 @@ struct MediaDetailView: View { } } } - + private struct RelatedMediasView: View { @ObservedObject var model: MediaDetailViewModel - + var body: some View { ZStack { if !model.relatedMedias.isEmpty { @@ -258,8 +257,7 @@ struct MediaDetailView: View { } } } - } - else { + } else { Color.clear } } @@ -269,11 +267,11 @@ struct MediaDetailView: View { extension MediaDetailView { private var analyticsPageTitle: String { - return AnalyticsPageTitle.media.rawValue + AnalyticsPageTitle.media.rawValue } - + private var analyticsPageLevels: [String]? { - return [AnalyticsPageLevel.play.rawValue] + [AnalyticsPageLevel.play.rawValue] } } diff --git a/TV Application/Sources/MediaDetailViewModel.swift b/TV Application/Sources/MediaDetailViewModel.swift index 0d7c8b1bb..a1b2edef2 100644 --- a/TV Application/Sources/MediaDetailViewModel.swift +++ b/TV Application/Sources/MediaDetailViewModel.swift @@ -11,12 +11,12 @@ import SRGUserData final class MediaDetailViewModel: ObservableObject { @Published var media: SRGMedia? - + var playAnalyticsClickEvent: AnalyticsClickEvent? var playAnalyticsClickEventMediaUrn: String? - + @Published private var mediaData: MediaData = .empty - + init() { // Drop initial values; relevant values are first assigned when the view appears $media @@ -30,7 +30,7 @@ final class MediaDetailViewModel: ObservableObject { Self.relatedMediasPublisher(for: media, from: self?.mediaData ?? .empty) ) .map { action, relatedMedias in - return MediaData(media: media, watchLaterAllowedAction: action, relatedMedias: relatedMedias) + MediaData(media: media, watchLaterAllowedAction: action, relatedMedias: relatedMedias) } .eraseToAnyPublisher() } @@ -38,41 +38,40 @@ final class MediaDetailViewModel: ObservableObject { .receive(on: DispatchQueue.main) .assign(to: &$mediaData) } - + var showTitle: String? { if let showTitle = media?.show?.title, showTitle.lowercased() != media?.title.lowercased() { - return showTitle - } - else { - return nil + showTitle + } else { + nil } } - + var youthProtectionColor: SRGYouthProtectionColor? { let youthProtectionColor = media?.youthProtectionColor return youthProtectionColor != SRGYouthProtectionColor.none ? youthProtectionColor : nil } - + var imageUrl: URL? { - return url(for: media?.image, size: .large) + url(for: media?.image, size: .large) } - + var watchLaterAllowedAction: WatchLaterAction { - return mediaData.watchLaterAllowedAction + mediaData.watchLaterAllowedAction } - + var relatedMedias: [SRGMedia] { - return mediaData.relatedMedias + mediaData.relatedMedias } - + func toggleWatchLater() { guard let media else { return } WatchLaterToggleMedia(media) { added, error in guard error == nil else { return } - + let action = added ? .add : .remove as AnalyticsListAction AnalyticsEvent.watchLater(action: action, source: .button, urn: media.urn).send() - + self.mediaData = MediaData(media: media, watchLaterAllowedAction: added ? .remove : .add, relatedMedias: self.mediaData.relatedMedias) } } @@ -89,7 +88,7 @@ extension MediaDetailViewModel { .map(\.data) .decode(type: Recommendation.self, decoder: JSONDecoder()) .map { recommendation in - return SRGDataProvider.current!.medias(withUrns: recommendation.urns) + SRGDataProvider.current!.medias(withUrns: recommendation.urns) } .switchToLatest() .replaceError(with: []) @@ -105,7 +104,7 @@ extension MediaDetailViewModel { let media: SRGMedia? let watchLaterAllowedAction: WatchLaterAction let relatedMedias: [SRGMedia] - + static var empty = Self(media: nil, watchLaterAllowedAction: .none, relatedMedias: []) } } diff --git a/TV Application/Sources/SceneDelegate.swift b/TV Application/Sources/SceneDelegate.swift index 21b038ad2..be5b430e7 100644 --- a/TV Application/Sources/SceneDelegate.swift +++ b/TV Application/Sources/SceneDelegate.swift @@ -11,24 +11,24 @@ import UIKit final class SceneDelegate: UIResponder { var window: UIWindow? - + private var cancellables = Set() -#if DEBUG || NIGHTLY || BETA - private var settingUpdatesCancellables = Set() -#endif - + #if DEBUG || NIGHTLY || BETA + private var settingUpdatesCancellables = Set() + #endif + private static func configureTabBarController(_ tabBarController: UITabBarController) { let appearance = UITabBarAppearance() appearance.configureWithDefaultBackground() - + appearance.backgroundColor = UIColor(white: 1, alpha: 0.1) appearance.backgroundEffect = UIBlurEffect(style: .dark) appearance.selectionIndicatorTintColor = .srgGray96 - + let font: UIFont = SRGFont.font(family: .text, weight: .medium, fixedSize: 28) let normalColor = UIColor.white let activeColor = UIColor.srgGray16 - + let normalItemAttributes: [NSAttributedString.Key: Any] = [ .font: font, .foregroundColor: normalColor @@ -37,7 +37,7 @@ final class SceneDelegate: UIResponder { .font: font, .foregroundColor: activeColor ] - + let inlineItemAppearance = UITabBarItemAppearance(style: .inline) inlineItemAppearance.normal.titleTextAttributes = normalItemAttributes inlineItemAppearance.normal.iconColor = normalColor @@ -46,77 +46,81 @@ final class SceneDelegate: UIResponder { inlineItemAppearance.focused.titleTextAttributes = activeItemAttributes inlineItemAppearance.focused.iconColor = activeColor appearance.inlineLayoutAppearance = inlineItemAppearance - + tabBarController.tabBar.standardAppearance = appearance tabBarController.view.backgroundColor = .srgGray16 } - + private static func applicationRootViewController() -> UIViewController { var viewControllers = [UIViewController]() - + let pageViewController = PageViewController(id: .video) pageViewController.tabBarItem = UITabBarItem(title: NSLocalizedString("Home", comment: "Home tab title"), image: nil, tag: 0) pageViewController.tabBarItem.accessibilityIdentifier = AccessibilityIdentifier.videosTabBarItem.value viewControllers.append(pageViewController) - + let configuration = ApplicationConfiguration.shared - -#if DEBUG - if let firstChannel = configuration.radioHomepageChannels.first { - let pageViewController = PageViewController(id: .audio(channel: firstChannel)) - pageViewController.tabBarItem = UITabBarItem(title: NSLocalizedString("Audios", comment: "Audios tab title"), image: nil, tag: 1) - pageViewController.tabBarItem.accessibilityIdentifier = AccessibilityIdentifier.audiosTabBarItem.value - viewControllers.append(pageViewController) - } -#endif - + + #if DEBUG + if ApplicationConfiguration.shared.isAudioContentHomepagePreferred { + let pageViewController = PageViewController(id: .audio(channel: nil)) + pageViewController.tabBarItem = UITabBarItem(title: NSLocalizedString("Audios", comment: "Audios tab title"), image: nil, tag: 1) + pageViewController.tabBarItem.accessibilityIdentifier = AccessibilityIdentifier.audiosTabBarItem.value + viewControllers.append(pageViewController) + } else if let firstChannel = configuration.radioHomepageChannels.first { + let pageViewController = PageViewController(id: .audio(channel: firstChannel)) + pageViewController.tabBarItem = UITabBarItem(title: NSLocalizedString("Audios", comment: "Audios tab title"), image: nil, tag: 1) + pageViewController.tabBarItem.accessibilityIdentifier = AccessibilityIdentifier.audiosTabBarItem.value + viewControllers.append(pageViewController) + } + #endif + if !configuration.liveHomeSections.isEmpty { let pageViewController = PageViewController(id: .live) pageViewController.tabBarItem = UITabBarItem(title: NSLocalizedString("Livestreams", comment: "Livestreams tab title"), image: nil, tag: 2) pageViewController.tabBarItem.accessibilityIdentifier = AccessibilityIdentifier.livestreamsTabBarItem.value viewControllers.append(pageViewController) } - + if !configuration.isTvGuideUnavailable { let programGuideViewController = ProgramGuideViewController() programGuideViewController.tabBarItem = UITabBarItem(title: NSLocalizedString("TV guide", comment: "TV program guide view title"), image: nil, tag: 3) programGuideViewController.tabBarItem.accessibilityIdentifier = AccessibilityIdentifier.tvGuideTabBarItem.value viewControllers.append(programGuideViewController) } - + if !configuration.areShowsUnavailable { let showsViewController = SectionViewController.showsViewController(forChannelUid: nil) showsViewController.tabBarItem = UITabBarItem(title: NSLocalizedString("Shows", comment: "Shows tab title"), image: nil, tag: 4) showsViewController.tabBarItem.accessibilityIdentifier = AccessibilityIdentifier.showsTabBarItem.value viewControllers.append(showsViewController) } - + let searchViewController = SearchViewController.viewController() searchViewController.tabBarItem = UITabBarItem(title: NSLocalizedString("Search", comment: "Search tab title"), image: nil, tag: 5) searchViewController.tabBarItem.accessibilityIdentifier = AccessibilityIdentifier.searchTabBarItem.value viewControllers.append(searchViewController) - + let profileViewController = UIHostingController(rootView: SettingsView()) profileViewController.tabBarItem = UITabBarItem(title: nil, image: UIImage(resource: .profileTab).withRenderingMode(.alwaysTemplate), tag: 6) profileViewController.tabBarItem.accessibilityLabel = PlaySRGAccessibilityLocalizedString("Profile", comment: "Profile button label on home view") profileViewController.tabBarItem.accessibilityIdentifier = AccessibilityIdentifier.profileTabBarItem.value viewControllers.append(profileViewController) - + if viewControllers.count > 1 { let tabBarController = UITabBarController() Self.configureTabBarController(tabBarController) tabBarController.viewControllers = viewControllers return tabBarController - } - else { + } else { return viewControllers.first! } } - + private func handleURLContexts(_ urlContexts: Set) { // FIXME: Works as long as only one context is received guard let urlContext = urlContexts.first else { return } - + let action = DeepLinkAction(from: urlContext) switch action.type { case .media: @@ -168,31 +172,33 @@ final class SceneDelegate: UIResponder { } extension SceneDelegate: UIWindowSceneDelegate { - func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + func scene(_ scene: UIScene, willConnectTo _: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { guard let windowScene = scene as? UIWindowScene else { return } - + let window = UIWindow(windowScene: windowScene) window.makeKeyAndVisible() window.rootViewController = Self.applicationRootViewController() self.window = window - + handleURLContexts(connectionOptions.urlContexts) - -#if DEBUG || NIGHTLY || BETA - Publishers.Merge3( - ApplicationSignal.settingUpdates(at: \.PlaySRGSettingPosterImages), - ApplicationSignal.settingUpdates(at: \.PlaySRGSettingServiceIdentifier), - ApplicationSignal.settingUpdates(at: \.PlaySRGSettingUserLocation) - ) - .debounce(for: 0.7, scheduler: DispatchQueue.main) - .sink { - window.rootViewController = Self.applicationRootViewController() - } - .store(in: &settingUpdatesCancellables) -#endif + + #if DEBUG || NIGHTLY || BETA + Publishers.MergeMany( + ApplicationSignal.settingUpdates(at: \.PlaySRGSettingPosterImages), + ApplicationSignal.settingUpdates(at: \.PlaySRGSettingSquareImages), + ApplicationSignal.settingUpdates(at: \.PlaySRGSettingAudioHomepageOption), + ApplicationSignal.settingUpdates(at: \.PlaySRGSettingServiceIdentifier), + ApplicationSignal.settingUpdates(at: \.PlaySRGSettingUserLocation) + ) + .debounce(for: 0.7, scheduler: DispatchQueue.main) + .sink { + window.rootViewController = Self.applicationRootViewController() + } + .store(in: &settingUpdatesCancellables) + #endif } - - func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + + func scene(_: UIScene, openURLContexts URLContexts: Set) { handleURLContexts(URLContexts) } } diff --git a/Translations/Localizable.strings b/Translations/Localizable.strings index 381d0fbc7..1e262f13e 100644 --- a/Translations/Localizable.strings +++ b/Translations/Localizable.strings @@ -95,6 +95,10 @@ /* Audio description availability setting label */ "Audio description availability" = "Audio description availability"; +/* Audio home page selection view title + Label of the button for audio homepage option selection */ +"Audio home page" = "Audio home page"; + /* Audios tab title Header for audio search results Search setting option @@ -159,7 +163,9 @@ /* Search setting */ "Date" = "Date"; -/* Poster images setting state */ +/* Audio homepage option setting state + Poster images setting state + Square images setting state */ "Default (current configuration)" = "Default (current configuration)"; /* User location setting state */ @@ -270,7 +276,8 @@ /* Explanation displayed in the alert asking the user to enable notifications */ "For the application to inform you when a new episode is available, notifications must be enabled." = "For the application to inform you when a new episode is available, notifications must be enabled."; -/* Poster images setting state */ +/* Poster images setting state + Square images setting state */ "Force" = "Force"; /* Total free space size displayed as a list footer */ @@ -299,7 +306,8 @@ /* Home tab title */ "Home" = "Home"; -/* Poster images setting state */ +/* Poster images setting state + Square images setting state */ "Ignore" = "Ignore"; /* User location setting state */ @@ -381,6 +389,12 @@ /* Message title displayed when the user is forced to update the application. */ "Mandatory update" = "Mandatory update"; +/* Many curated audio homepages option setting state */ +"Many curated pages (PAC landing pages)" = "Many curated pages (PAC landing pages)"; + +/* Many predefined audio homepage option setting state */ +"Many predefined pages" = "Many predefined pages"; + /* Message on top screen when trying to open a media in the download list and the media is not downloaded. */ "Media not available yet" = "Media not available yet"; @@ -473,6 +487,9 @@ Title of the search settings button to apply settings */ "OK" = "OK"; +/* One curated audio homepage option setting state */ +"One curated page (PAC Audio)" = "One curated page (PAC Audio)"; + /* Label of the button opening system settings */ "Open system settings" = "Open system settings"; @@ -661,6 +678,10 @@ Title label used to present sport scheduled livestream medias */ "Sport livestreams" = "Sport livestreams"; +/* Label of the button for Podcast square image format selection + Podcast square image format selection view title */ +"Square images" = "Square images"; + /* Server setting name */ "Stage" = "Stage"; diff --git a/UITests/Screenshots/Sources/ApplicationScreenshots~ios.swift b/UITests/Screenshots/Sources/ApplicationScreenshots~ios.swift index 71b01a829..76914014a 100644 --- a/UITests/Screenshots/Sources/ApplicationScreenshots~ios.swift +++ b/UITests/Screenshots/Sources/ApplicationScreenshots~ios.swift @@ -7,70 +7,67 @@ import XCTest class ApplicationScreenshots: XCTestCase { - private static let configuration: NSDictionary = { - if let path = Bundle(for: ApplicationScreenshots.self).path(forResource: "Configuration", ofType: "plist") { - return NSDictionary(contentsOfFile: path) ?? [:] - } - else { - return [:] - } - }() - + private static let configuration: NSDictionary = if let path = Bundle(for: ApplicationScreenshots.self).path(forResource: "Configuration", ofType: "plist") { + NSDictionary(contentsOfFile: path) ?? [:] + } else { + [:] + } + override func setUp() { super.setUp() - + let app = XCUIApplication() setupSnapshot(app) app.launch() - + continueAfterFailure = false - + XCUIDevice.shared.orientation = (UIDevice.current.userInterfaceIdiom == .pad) ? .landscapeLeft : .portrait } - + func testSnapshots() { if let videosTabBarItem = tabBarItem(withIdentifier: AccessibilityIdentifier.videosTabBarItem.value) { videosTabBarItem.tap() sleep(10) snapshot("1-VideosHomeScreen") } - + if let audiosTabBarItem = tabBarItem(withIdentifier: AccessibilityIdentifier.audiosTabBarItem.value) { audiosTabBarItem.tap() sleep(10) snapshot("2-AudiosHomeScreen") } - + if let livestreamsTabBarItem = tabBarItem(withIdentifier: AccessibilityIdentifier.livestreamsTabBarItem.value) { livestreamsTabBarItem.tap() sleep(10) snapshot("3-LiveHomeScreen") - + if let radioCellIndex = Self.configuration["RadioCellIndex"] as? Int, let radioCell = collectionViewCell(radioCellIndex) { radioCell.tap() sleep(10) snapshot("4-RadioLivePlayer") - + if let closeButton = button(withIdentifier: AccessibilityIdentifier.closeButton.value) { closeButton.tap() } } } - + if let showsTabBarItem = tabBarItem(withIdentifier: AccessibilityIdentifier.showsTabBarItem.value) { showsTabBarItem.tap() sleep(10) snapshot("5-ShowsScreen") } - + if let searchTabBarItem = tabBarItem(withIdentifier: AccessibilityIdentifier.searchTabBarItem.value), let searchText = Self.configuration["SearchText"] as? String { searchTabBarItem.tap() - + let searchField = XCUIApplication().searchFields.firstMatch searchField.tap() searchField.typeText("\(searchText)\n") - + sleep(10) snapshot("6-SearchScreen") } @@ -82,12 +79,12 @@ extension ApplicationScreenshots { let tabBarItem = XCUIApplication().tabBars.buttons[identifier] return tabBarItem.exists ? tabBarItem : nil } - + func button(withIdentifier identifier: String) -> XCUIElement? { let button = XCUIApplication().buttons[identifier] return button.exists ? button : nil } - + func collectionViewCell(_ index: Int) -> XCUIElement? { let cell = XCUIApplication().collectionViews.firstMatch.cells.element(boundBy: index) return cell.exists ? cell : nil diff --git a/UITests/Screenshots/Sources/ApplicationScreenshots~tvos.swift b/UITests/Screenshots/Sources/ApplicationScreenshots~tvos.swift index 8e7fb138c..73cd9fadb 100644 --- a/UITests/Screenshots/Sources/ApplicationScreenshots~tvos.swift +++ b/UITests/Screenshots/Sources/ApplicationScreenshots~tvos.swift @@ -7,29 +7,26 @@ import XCTest class ApplicationScreenshots: XCTestCase { - private static let configuration: NSDictionary = { - if let path = Bundle(for: ApplicationScreenshots.self).path(forResource: "Configuration", ofType: "plist") { - return NSDictionary(contentsOfFile: path) ?? [:] - } - else { - return [:] - } - }() - + private static let configuration: NSDictionary = if let path = Bundle(for: ApplicationScreenshots.self).path(forResource: "Configuration", ofType: "plist") { + NSDictionary(contentsOfFile: path) ?? [:] + } else { + [:] + } + override func setUp() { super.setUp() - + let app = XCUIApplication() setupSnapshot(app) app.launch() - + continueAfterFailure = false } - + func testSnapshots() { // Wait a bit for the focus engine to determine the first focused item sleep(5) - + // Navigate tabs with the remote and perform the action depending on the tab which is reached var previousIdentifier: String? while true { @@ -37,7 +34,7 @@ class ApplicationScreenshots: XCTestCase { break } previousIdentifier = identifier - + switch identifier { case AccessibilityIdentifier.videosTabBarItem.value: sleep(10) @@ -49,7 +46,7 @@ class ApplicationScreenshots: XCTestCase { sleep(10) snapshot("3-TvGuideScreen") case AccessibilityIdentifier.showsTabBarItem.value: - sleep(20) // Need more time for some BUs + sleep(20) // Need more time for some BUs snapshot("4-ShowsScreen") case AccessibilityIdentifier.searchTabBarItem.value: if let searchText = Self.configuration["SearchText"] as? String { @@ -61,26 +58,25 @@ class ApplicationScreenshots: XCTestCase { default: () } - + moveToNextTabBarItem() } } - + private var focusedIdentifier: String? { // String-based predicate recommended by `elements(matching:)` documentation let identifier = XCUIApplication().descendants(matching: .any).element(matching: NSPredicate(format: "hasFocus == true")).identifier return !identifier.isEmpty ? identifier : nil } - + private func moveToNextTabBarItem() { let remote = XCUIRemote.shared - + // Press Menu if to return to the tab bar if needed. All our tab bar accessibility identifiers contain 'TabBarItem', // which allows us to test against all possible identifiers without explicitly listing them if let identifier = focusedIdentifier, identifier.contains("TabBarItem") { remote.press(.right) - } - else { + } else { remote.press(.menu) remote.press(.right) } diff --git a/UITests/Screenshots/Sources/Helpers/SnapshotHelper.swift b/UITests/Screenshots/Sources/Helpers/SnapshotHelper.swift index 5628b89e8..8acb8d566 100644 --- a/UITests/Screenshots/Sources/Helpers/SnapshotHelper.swift +++ b/UITests/Screenshots/Sources/Helpers/SnapshotHelper.swift @@ -44,9 +44,9 @@ enum SnapshotError: Error, CustomDebugStringConvertible { var debugDescription: String { switch self { case .cannotFindSimulatorHomeDirectory: - return "Couldn't find simulator home location. Please, check SIMULATOR_HOST_HOME env variable." + "Couldn't find simulator home location. Please, check SIMULATOR_HOST_HOME env variable." case .cannotRunOnPhysicalDevice: - return "Can't use Snapshot on a physical device." + "Can't use Snapshot on a physical device." } } } @@ -58,8 +58,9 @@ open class Snapshot: NSObject { static var waitForAnimations = true static var cacheDirectory: URL? static var screenshotsDirectory: URL? { - return cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true) + cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true) } + static var deviceLanguage = "" static var currentLocale = "" @@ -79,7 +80,7 @@ open class Snapshot: NSObject { } class func setLanguage(_ app: XCUIApplication) { - guard let cacheDirectory = self.cacheDirectory else { + guard let cacheDirectory else { NSLog("CacheDirectory is not set - probably running on a physical device?") return } @@ -96,7 +97,7 @@ open class Snapshot: NSObject { } class func setLocale(_ app: XCUIApplication) { - guard let cacheDirectory = self.cacheDirectory else { + guard let cacheDirectory else { NSLog("CacheDirectory is not set - probably running on a physical device?") return } @@ -110,7 +111,7 @@ open class Snapshot: NSObject { NSLog("Couldn't detect/set locale...") } - if currentLocale.isEmpty && !deviceLanguage.isEmpty { + if currentLocale.isEmpty, !deviceLanguage.isEmpty { currentLocale = Locale(identifier: deviceLanguage).identifier } @@ -120,7 +121,7 @@ open class Snapshot: NSObject { } class func setLaunchArguments(_ app: XCUIApplication) { - guard let cacheDirectory = self.cacheDirectory else { + guard let cacheDirectory else { NSLog("CacheDirectory is not set - probably running on a physical device?") return } @@ -148,12 +149,12 @@ open class Snapshot: NSObject { NSLog("snapshot: \(name)") // more information about this, check out https://docs.fastlane.tools/actions/snapshot/#how-does-it-work - if Self.waitForAnimations { + if waitForAnimations { sleep(1) // Waiting for the animation to be finished (kind of) } #if os(OSX) - guard let app = self.app else { + guard let app else { NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") return } @@ -168,9 +169,9 @@ open class Snapshot: NSObject { let screenshot = XCUIScreen.main.screenshot() #if os(iOS) && !targetEnvironment(macCatalyst) - let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image + let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image #else - let image = screenshot.image + let image = screenshot.image #endif guard var simulator = ProcessInfo().environment["SIMULATOR_DEVICE_NAME"], let screenshotsDir = screenshotsDirectory else { return } @@ -216,7 +217,7 @@ open class Snapshot: NSObject { return #endif - guard let app = self.app else { + guard let app else { NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") return } @@ -280,7 +281,7 @@ private extension XCUIElementQuery { return element.isNetworkLoadingIndicator } - return self.containing(isNetworkLoadingIndicator) + return containing(isNetworkLoadingIndicator) } @MainActor @@ -297,13 +298,13 @@ private extension XCUIElementQuery { return element.isStatusBar(deviceWidth) } - return self.containing(isStatusBar) + return containing(isStatusBar) } } private extension CGFloat { func isBetween(_ numberA: CGFloat, and numberB: CGFloat) -> Bool { - return numberA...numberB ~= self + numberA ... numberB ~= self } } diff --git a/WhatsNew-iOS-beta.json b/WhatsNew-iOS-beta.json index e196ec0d7..b9057518c 100755 --- a/WhatsNew-iOS-beta.json +++ b/WhatsNew-iOS-beta.json @@ -230,5 +230,7 @@ "3.8.5-451": "- AppStore release.", "3.8.6-452": "Branch beta\n- Show page header updated.\n- Topic colors added.\n- Some font weights and gray colors updated for better readability.", "3.8.6-453": "- Show page header updated.\n- Topic colors added.\n- Page section headers can open an other content page.\n- Shared URLs for Swiss musical radios updated.", - "3.8.6-454": "- AppStore release." + "3.8.6-454": "- AppStore release.", + "3.8.7-455": "- Update dependencies to run iPad application on Vision Pro device.\n- Internal beta new options for audio content pages.", + "3.8.7-456": "- Allow red badge in UI element to expand horizontally if the contained text is long.\n- Reduce number of taps to open an highlighted media.\n- Use 16:9 images in alphabetical TV shows view. [SRF]" } \ No newline at end of file diff --git a/WhatsNew-tvOS-beta.json b/WhatsNew-tvOS-beta.json index 86483b907..6d168f788 100755 --- a/WhatsNew-tvOS-beta.json +++ b/WhatsNew-tvOS-beta.json @@ -96,5 +96,7 @@ "1.8.5-451": "- AppStore release.", "1.8.6-452": "Branch beta\n- Show page header updated.\n- Topic colors added.\n- Some font weights and gray colors updated for better readability.", "1.8.6-453": "- Show page header updated.\n- Topic colors added.\n- Page section headers can open an other content page.", - "1.8.6-454": "- AppStore release." + "1.8.6-454": "- AppStore release.", + "1.8.7-455": "- Internal beta new options for audio content pages.", + "1.8.7-456": "- Allow red badge in UI element to expand horizontally if the contained text is long.\n- Reduce number of clicks to open an highlighted media.\n- Use 16:9 images in alphabetical TV shows view. [SRF]" } \ No newline at end of file diff --git a/Xcode/Shared/Common.xcconfig b/Xcode/Shared/Common.xcconfig index 926e9f538..76c998d05 100755 --- a/Xcode/Shared/Common.xcconfig +++ b/Xcode/Shared/Common.xcconfig @@ -2,7 +2,7 @@ PRODUCT_BUNDLE_IDENTIFIER = $(BU__BUNDLE_IDENTIFIER_PREFIX)$(BU__BUNDLE_IDENTIFI PRODUCT_NAME = $(BU__PRODUCT_NAME)$(TARGET__PRODUCT_NAME_SUFFIX) // Version information -CURRENT_PROJECT_VERSION = 454 +CURRENT_PROJECT_VERSION = 456 GCC_PREPROCESSOR_DEFINITIONS[config=Beta] = BETA=1 GCC_PREPROCESSOR_DEFINITIONS[config=Beta_AppCenter] = BETA=1 APPCENTER=1 diff --git a/Xcode/Shared/Targets/iOS/Common.xcconfig b/Xcode/Shared/Targets/iOS/Common.xcconfig index c5f7b49ac..a0e296dab 100755 --- a/Xcode/Shared/Targets/iOS/Common.xcconfig +++ b/Xcode/Shared/Targets/iOS/Common.xcconfig @@ -1,7 +1,7 @@ #include "Xcode/Shared/Common.xcconfig" // Version information -MARKETING_VERSION = 3.8.6 +MARKETING_VERSION = 3.8.7 SDKROOT = iphoneos TARGETED_DEVICE_FAMILY=1,2 diff --git a/Xcode/Shared/Targets/tvOS/Common.xcconfig b/Xcode/Shared/Targets/tvOS/Common.xcconfig index 5dca976af..3f7908098 100755 --- a/Xcode/Shared/Targets/tvOS/Common.xcconfig +++ b/Xcode/Shared/Targets/tvOS/Common.xcconfig @@ -1,7 +1,7 @@ #include "Xcode/Shared/Common.xcconfig" // Version information -MARKETING_VERSION = 1.8.6 +MARKETING_VERSION = 1.8.7 SDKROOT = appletvos TARGETED_DEVICE_FAMILY=3 diff --git a/docs/README.md b/docs/README.md index bf21d289b..d5d695b9a 100755 --- a/docs/README.md +++ b/docs/README.md @@ -100,7 +100,7 @@ If you want to contribute to the project as an external contributor, have a look Checking quality, the project requires command-line tools: ``` -brew install swiftlint shellcheck yamllint +brew install swiftlint swiftformat shellcheck yamllint ``` For `rubocop`, be sure that this tool is available on your system, or execute: diff --git a/docs/REMOTE_CONFIGURATION.md b/docs/REMOTE_CONFIGURATION.md index 2091cc81e..90e654768 100755 --- a/docs/REMOTE_CONFIGURATION.md +++ b/docs/REMOTE_CONFIGURATION.md @@ -68,6 +68,7 @@ The keys common to both TV and radio channels JSON dictionaries are: * `collapsed`: Collapsed when added to the view. * `expanded`: Expanded when added to the view. * `shareURL` (optional, string): The URL used to share the channel website. +* `contentPageId` (optional, string) - BETA ONLY: The page identifier of the content page to use for the channel page. If omitted, the preconfigured page is used with the related sections for this channel. The radio channel JSON dictionaries have one more key: @@ -87,7 +88,8 @@ The radio channel JSON dictionaries have one more key: ## Audio homepage -`audioHomeSections` (optional, string, multiple): The sections to be displayed on the audio homepage of a radio channel, in the order they must appear. +* `audioHomeSections` (optional, string, multiple): The sections to be displayed on the audio homepage of a radio channel, in the order they must appear. +* `audioContentHomepagePreferred` (optional, boolean) - BETA ONLY: Set to `true` iff audio tab only needs one content homepage to be displayed and ignores radio channel homepages. If omitted, `false`. ### Home sections: @@ -138,6 +140,11 @@ Feeds * `searchSettingSubtitledHidden` (optional, boolean): Set to `true` to hide the subtitled option in the search settings. * `showsSearchHidden ` (optional, boolean): Set to `true` to hide show search results. +## Resume playback + +* `endTolerance` (optional, number): Duration tolerance in seconds at the end of a media to consider it as finished and then subsequently not resume it from last position, but from the beginning. If empty, the default value is 0 seconds. +* `endToleranceRatio` (optional, number): Percentage tolerance as a floating number at the end of a media to consider it as finished and then subsequently not resume it from last position, but from the beginning. If empty, the default value is 0.0. + ## Continuous playback * `continuousPlaybackPlayerViewTransitionDuration` (optional, number): Duration in seconds for continuous playback when the player view is displayed. If empty, continuous playback is disabled; if equal to 0, upcoming media playback starts immediately. @@ -160,7 +167,8 @@ Feeds * `hiddenOnboardings` (optional, string, multiple): Identifier list of onboardings which must be hidden. * `historySynchronizationInterval` (optional, number): Duration in seconds for history synchronization. If omitted, defaults to 30 seconds. Miminum value is 10 seconds. * `minimumSocialViewCount` (optional, number): The threshold under which social view counts will not be displayed. If omitted, 0. -* `posterImagesEnabled` (optional, boolean): If set to `true`, poster images are displayed where appropriate. +* `posterImagesEnabled` (optional, boolean): If set to `true`, show poster images are displayed where appropriate. +* `squareImagesEnabled` (optional, boolean): If set to `true`, show square images are displayed where appropriate. * `showsUnavailable` (optional, boolean): If set to `true`, all features related to shows are removed. * `subtitleAvailabilityHidden` (optional, boolean): Set to `true` to hide the subtitle availability setting. * `discoverySubtitleOptionLanguage` (optional, string): Set system subtitle language to this value once and at the beginning to help user discover that content is subtitled in that language. diff --git a/hooks/pre-commit b/hooks/pre-commit index 92e241f9f..32de97f79 100755 --- a/hooks/pre-commit +++ b/hooks/pre-commit @@ -5,7 +5,7 @@ #================================================================ eval "$(rbenv init -)" -PATH="$(which swiftlint):$(which ruby):$(which shellcheck):$(which yamllint):$(which pod):$PATH" +PATH="$(which swiftlint):$(which swiftformat):$(which ruby):$(which shellcheck):$(which yamllint):$(which pod):$PATH" if Scripts/check-quality.sh only-changes; then echo "✅ Quality checked"