mirror of
https://github.com/yattee/yattee.git
synced 2025-04-28 16:00:33 +05:30
Compare commits
No commits in common. "main" and "v1.4.5" have entirely different histories.
18
.github/workflows/release.yml
vendored
18
.github/workflows/release.yml
vendored
@ -29,7 +29,7 @@ jobs:
|
|||||||
name: Releasing ${{ matrix.lane }} version to TestFlight
|
name: Releasing ${{ matrix.lane }} version to TestFlight
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
- uses: ruby/setup-ruby@v1
|
- uses: ruby/setup-ruby@v1
|
||||||
with:
|
with:
|
||||||
ruby-version: '3.0'
|
ruby-version: '3.0'
|
||||||
@ -38,13 +38,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
sed -i '' 's/match Development/match AppStore/' Yattee.xcodeproj/project.pbxproj
|
sed -i '' 's/match Development/match AppStore/' Yattee.xcodeproj/project.pbxproj
|
||||||
sed -i '' 's/iPhone Developer/iPhone Distribution/' Yattee.xcodeproj/project.pbxproj
|
sed -i '' 's/iPhone Developer/iPhone Distribution/' Yattee.xcodeproj/project.pbxproj
|
||||||
- uses: maxim-lobanov/setup-xcode@v1
|
|
||||||
with:
|
|
||||||
xcode-version: latest-stable
|
|
||||||
- uses: maierj/fastlane-action@v3.0.0
|
- uses: maierj/fastlane-action@v3.0.0
|
||||||
with:
|
with:
|
||||||
lane: ${{ matrix.lane }}
|
lane: ${{ matrix.lane }}
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.lane }} build
|
name: ${{ matrix.lane }} build
|
||||||
path: fastlane/builds/**/*.ipa
|
path: fastlane/builds/**/*.ipa
|
||||||
@ -53,7 +50,7 @@ jobs:
|
|||||||
name: Build and notarize macOS app
|
name: Build and notarize macOS app
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
- uses: ruby/setup-ruby@v1
|
- uses: ruby/setup-ruby@v1
|
||||||
with:
|
with:
|
||||||
ruby-version: '3.0'
|
ruby-version: '3.0'
|
||||||
@ -62,9 +59,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
sed -i '' 's/match AppStore/match Direct/' Yattee.xcodeproj/project.pbxproj
|
sed -i '' 's/match AppStore/match Direct/' Yattee.xcodeproj/project.pbxproj
|
||||||
sed -i '' 's/3rd Party Mac Developer Application/Developer ID Application/' Yattee.xcodeproj/project.pbxproj
|
sed -i '' 's/3rd Party Mac Developer Application/Developer ID Application/' Yattee.xcodeproj/project.pbxproj
|
||||||
- uses: maxim-lobanov/setup-xcode@v1
|
|
||||||
with:
|
|
||||||
xcode-version: latest-stable
|
|
||||||
- uses: maierj/fastlane-action@v3.0.0
|
- uses: maierj/fastlane-action@v3.0.0
|
||||||
with:
|
with:
|
||||||
lane: mac build_and_notarize
|
lane: mac build_and_notarize
|
||||||
@ -76,7 +70,7 @@ jobs:
|
|||||||
echo "ZIP_PATH=fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS/Yattee-${{ env.VERSION_NUMBER }}-macOS.zip" >> $GITHUB_ENV
|
echo "ZIP_PATH=fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS/Yattee-${{ env.VERSION_NUMBER }}-macOS.zip" >> $GITHUB_ENV
|
||||||
- name: ZIP build
|
- name: ZIP build
|
||||||
run: /usr/bin/ditto -c -k --keepParent ${{ env.APP_PATH }} ${{ env.ZIP_PATH }}
|
run: /usr/bin/ditto -c -k --keepParent ${{ env.APP_PATH }} ${{ env.ZIP_PATH }}
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: mac notarized build
|
name: mac notarized build
|
||||||
path: ${{ env.ZIP_PATH }}
|
path: ${{ env.ZIP_PATH }}
|
||||||
@ -86,10 +80,10 @@ jobs:
|
|||||||
name: Create GitHub release
|
name: Create GitHub release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
- run: echo "BUILD_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 CURRENT_PROJECT_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
|
- run: echo "BUILD_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 CURRENT_PROJECT_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
|
||||||
- run: echo "VERSION_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 MARKETING_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
|
- run: echo "VERSION_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 MARKETING_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
path: artifacts
|
path: artifacts
|
||||||
- uses: ncipollo/release-action@v1
|
- uses: ncipollo/release-action@v1
|
||||||
|
@ -6,9 +6,15 @@ disabled_rules:
|
|||||||
- opening_brace
|
- opening_brace
|
||||||
- number_separator
|
- number_separator
|
||||||
- multiline_arguments
|
- multiline_arguments
|
||||||
|
opt_in_rules:
|
||||||
- implicit_return
|
- implicit_return
|
||||||
excluded:
|
excluded:
|
||||||
- Vendor
|
- Vendor
|
||||||
- Tests Apple TV
|
- Tests Apple TV
|
||||||
- Tests iOS
|
- Tests iOS
|
||||||
- Tests macOS
|
- Tests macOS
|
||||||
|
|
||||||
|
implicit_return:
|
||||||
|
included:
|
||||||
|
- function
|
||||||
|
- getter
|
||||||
|
184
CHANGELOG.md
184
CHANGELOG.md
@ -1,123 +1,67 @@
|
|||||||
## Build 199
|
## Build 155
|
||||||
|
* Fixed reported crashes
|
||||||
|
* Minor performance improvements
|
||||||
|
|
||||||
## What's Changed
|
## Previous Builds
|
||||||
* Add support for invidious companion by @lifo9 in https://github.com/yattee/yattee/pull/863
|
* Fixed issue where AVPlayer would pause playing on exiting fullscreen
|
||||||
* Translations update from Hosted Weblate by @weblate in https://github.com/yattee/yattee/pull/851
|
* Fixed issue with empty button appearing on subscriptions list on tvOS
|
||||||
|
|
||||||
## Previous builds
|
* Fixed issue with AVPlayer not always using full width
|
||||||
* Add skip, play/pause, and fullscreen shortcuts to macOS player (by @rickykresslein)
|
* Fixed issue with layout when switching backend while in fullscreen
|
||||||
* Added Settings Import/Export
|
* Fixed issue with updating watched time on closing video
|
||||||
* Export all settings, instances and accounts
|
* Fixed issue with adjusting AVPlayer playback rate when using system controls
|
||||||
* Import selected elements from the file
|
* Fixed issue where comments would load indefinitely
|
||||||
* Include unencrypted passwords in the export or provide them during the import
|
* Improved Home buttons layout on tvOS
|
||||||
* Import via URL for tvOS
|
* Reverted change to placeholders as were causing issues to properly display loading status, will be revisited in future
|
||||||
* Added Controls setting "Action button labels" icon or icon and text
|
* Fixed performance issue with swiping back to subscribed channels list
|
||||||
* Added Advanced setting for MPV: "deinterlace"
|
* Fixed reported crashes
|
||||||
* Add help text to all header buttons (by @rickykresslein)
|
|
||||||
* History Setting: hide the recent activity in the sidebar or limit the number of items shown (by @rickykresslein)
|
|
||||||
* Fix issues with empty comments (by @stonerl)
|
|
||||||
* Improved Invidious comments (by @stonerl)
|
|
||||||
* Allow import of accounts to manually added (not imported) instances
|
|
||||||
* Add import export of missing settings
|
|
||||||
* macOS: Fix settings windows layout
|
|
||||||
* Fix seek OSD layout on tvOS, revert OSD position
|
|
||||||
* Allow users to disable fullscreen swipe gesture by @stonerl in https://github.com/yattee/yattee/pull/814
|
|
||||||
* Proper audio interrupt and route change handling by @stonerl in https://github.com/yattee/yattee/pull/815
|
|
||||||
* Improved subtitle handling by @stonerl in https://github.com/yattee/yattee/pull/817
|
|
||||||
* Improvements to MPVGLView by @stonerl in https://github.com/yattee/yattee/pull/818
|
|
||||||
* Add drag gestures to video details by @stonerl in https://github.com/yattee/yattee/pull/820
|
|
||||||
* Fix uneven playback when using MPV and not syncing refreshrate by @blennster in https://github.com/yattee/yattee/pull/833
|
|
||||||
* Norwegian Language by @mmaalo in https://github.com/yattee/yattee/pull/834
|
|
||||||
* Translations update from Hosted Weblate by @weblate in https://github.com/yattee/yattee/pull/836
|
|
||||||
* Update MPVKit to v0.39.0 by @stonerl in https://github.com/yattee/yattee/pull/824
|
|
||||||
* Update SwiftUI-Introspect by @stonerl in https://github.com/yattee/yattee/pull/813
|
|
||||||
* Orientation/Fullscreen fixes and cleanup by @stonerl in https://github.com/yattee/yattee/pull/806
|
|
||||||
* More robust resolution handling by @stonerl in https://github.com/yattee/yattee/pull/807
|
|
||||||
* MPV: improved A/V sync by @stonerl in https://github.com/yattee/yattee/pull/805
|
|
||||||
* Retry loading video before presenting error by @stonerl in https://github.com/yattee/yattee/pull/810
|
|
||||||
* Refactor Search by @stonerl in https://github.com/yattee/yattee/pull/809
|
|
||||||
* iOS: Simplified fullscreen and orientation by @stonerl in https://github.com/yattee/yattee/pull/786
|
|
||||||
* macOS: only apply player shortcuts when window is active by @stonerl in https://github.com/yattee/yattee/pull/802
|
|
||||||
* player controls: add background opacity selection by @stonerl in https://github.com/yattee/yattee/pull/799
|
|
||||||
* add missing Shorts resolutions by @stonerl in https://github.com/yattee/yattee/pull/797
|
|
||||||
* use -O1 on macOS by @stonerl in https://github.com/yattee/yattee/pull/801
|
|
||||||
* Gestures: swipe up toggles fullscreen by @stonerl in https://github.com/yattee/yattee/pull/778
|
|
||||||
* don’t open video when dismissing context menu by @stonerl in https://github.com/yattee/yattee/pull/780
|
|
||||||
* mpv: remove video layer when entering background by @stonerl in https://github.com/yattee/yattee/pull/793
|
|
||||||
* hi-res invidious logos by @stonerl in https://github.com/yattee/yattee/pull/791
|
|
||||||
* enable -O3 by @stonerl in https://github.com/yattee/yattee/pull/794
|
|
||||||
* Better audio ducking by @stonerl in https://github.com/yattee/yattee/pull/779
|
|
||||||
* fix picture in picture by @stonerl in https://github.com/yattee/yattee/pull/789
|
|
||||||
* Invidious: propper HTTP basic auth support by @stonerl in https://github.com/yattee/yattee/pull/762
|
|
||||||
* Apply correct orientation by @stonerl in https://github.com/yattee/yattee/pull/770
|
|
||||||
* Circular Invidious logo by @stonerl in https://github.com/yattee/yattee/pull/769
|
|
||||||
* Video Thumbnails: retry 3 times fetching from URL by @stonerl in https://github.com/yattee/yattee/pull/768
|
|
||||||
* Make thumbnail fill the view in music mode by @stonerl in https://github.com/yattee/yattee/pull/766
|
|
||||||
* Changes to defaults by @stonerl in https://github.com/yattee/yattee/pull/767
|
|
||||||
* Fixed fullscreen handling for backgrounding by @stonerl in https://github.com/yattee/yattee/pull/772
|
|
||||||
* Update now playing info when using system controls – Partial fix for 503 by @stonerl in https://github.com/yattee/yattee/pull/765
|
|
||||||
* Stop making videos with unknown length shorts. by @derspyy in https://github.com/yattee/yattee/pull/849
|
|
||||||
* Add Hungarian to locales list
|
|
||||||
* Fix crash on HLS live playback by @stonerl in https://github.com/yattee/yattee/pull/775
|
|
||||||
* Fix mpv crashing on macOS by @stonerl in https://github.com/yattee/yattee/pull/754
|
|
||||||
* Refreshed icons for iOS and macOS by @stonerl in https://github.com/yattee/yattee/pull/752
|
|
||||||
* Add new MPVKit repo by @stonerl in https://github.com/yattee/yattee/pull/753
|
|
||||||
* Add Chinese (Simplified) - zh-Hans to LanguageCodes by @stonerl in https://github.com/yattee/yattee/pull/757
|
|
||||||
* Color changes to VideoActions by @stonerl in https://github.com/yattee/yattee/pull/759
|
|
||||||
* Hide VideoActions Bar when no buttons is visible by @stonerl in https://github.com/yattee/yattee/pull/760
|
|
||||||
* Improved stream resolution handling by @stonerl in https://github.com/yattee/yattee/pull/747
|
|
||||||
* Fix some potential crashes by @stonerl in https://github.com/yattee/yattee/pull/748
|
|
||||||
* Fix regression and improve curentChapter handling by @stonerl in https://github.com/yattee/yattee/pull/749
|
|
||||||
* Refined chapter font scaling by @stonerl in https://github.com/yattee/yattee/pull/750
|
|
||||||
* Improved thumbnail handling by @stonerl in https://github.com/yattee/yattee/pull/740
|
|
||||||
* iOS: make timestamps in comments touchable by @stonerl in https://github.com/yattee/yattee/pull/741
|
|
||||||
* Improvements to opening channels from Videos by @stonerl in https://github.com/yattee/yattee/pull/742
|
|
||||||
* Allow hiding comments by @stonerl in https://github.com/yattee/yattee/pull/744
|
|
||||||
* Add option to exit fullscreen on end by @stonerl in https://github.com/yattee/yattee/pull/570
|
|
||||||
* Only updateWatch status while video is playing by @stonerl in https://github.com/yattee/yattee/pull/745
|
|
||||||
* Xcode 16 - update recommended settings by @stonerl in https://github.com/yattee/yattee/pull/737
|
|
||||||
* Translations update from Hosted Weblate by @weblate in https://github.com/yattee/yattee/pull/724
|
|
||||||
* tvOS: Allow account picker by long pressing channels button in subscriptions view by @patelhiren in https://github.com/yattee/yattee/pull/704
|
|
||||||
* tvOS: Refined Subscriptions View by @patelhiren in https://github.com/yattee/yattee/pull/697
|
|
||||||
* More responsive UI when Favorites are used. by @stonerl in https://github.com/yattee/yattee/pull/695
|
|
||||||
* Improved conditional proxying by @stonerl in https://github.com/yattee/yattee/pull/696
|
|
||||||
* Don't show related in sidebar when disabled in settings by @stonerl in https://github.com/yattee/yattee/pull/635
|
|
||||||
* Handle audio session interrupts by other media by @stonerl in https://github.com/yattee/yattee/pull/640
|
|
||||||
* Only show Queue header in sidebar view by @stonerl in https://github.com/yattee/yattee/pull/642
|
|
||||||
* SponsorBlock Improvements by @stonerl in https://github.com/yattee/yattee/pull/639
|
|
||||||
* Chapter title on jump by @stonerl in https://github.com/yattee/yattee/pull/655
|
|
||||||
* Restart finished video by @stonerl in https://github.com/yattee/yattee/pull/646
|
|
||||||
* SponsorBlock jump to end instead of pausing by @stonerl in https://github.com/yattee/yattee/pull/648
|
|
||||||
* Call correct class of SDImageAWebPCoder by @stonerl in https://github.com/yattee/yattee/pull/664
|
|
||||||
* Fix handling and displaying captions by @stonerl in https://github.com/yattee/yattee/pull/636
|
|
||||||
* Advanced settings: make number fields .numPad by @stonerl in https://github.com/yattee/yattee/pull/661
|
|
||||||
* Preserve time on stream change by @stonerl in https://github.com/yattee/yattee/pull/651
|
|
||||||
* Switch to previous backend when leaving PiP by @stonerl in https://github.com/yattee/yattee/pull/641
|
|
||||||
* Handle deep links by @timonus in https://github.com/yattee/yattee/pull/645
|
|
||||||
* Music Mode: don't bindPlayerToLayer when entering foreground by @stonerl in https://github.com/yattee/yattee/pull/644
|
|
||||||
* Allow user to disable thumbnails and jump to current chapter in horizontal view by @stonerl in https://github.com/yattee/yattee/pull/665
|
|
||||||
* Rework qualitiy settings by @stonerl in https://github.com/yattee/yattee/pull/650
|
|
||||||
* HLS: set target bitrate / AVPlayer: higher resolution by @stonerl in https://github.com/yattee/yattee/pull/667
|
|
||||||
* Fix #619: Remove ports from shared YouTube links by @0x000C in https://github.com/yattee/yattee/pull/627
|
|
||||||
* XCode enable IDEPreferLogStreaming by @stonerl in https://github.com/yattee/yattee/pull/638
|
|
||||||
* Conditional proxying by @stonerl in https://github.com/yattee/yattee/pull/662
|
|
||||||
* HomeView: Changes to Favourites and History Widget by @stonerl in https://github.com/yattee/yattee/pull/672
|
|
||||||
* Snappy UI - Offloading non UI task to background threads by @stonerl in https://github.com/yattee/yattee/pull/671
|
|
||||||
* Fix PiP Mode Not Working Using MPV by @stonerl in https://github.com/yattee/yattee/pull/676
|
|
||||||
* Fix thumbnails failing to load on tvOS by @patelhiren in https://github.com/yattee/yattee/pull/688
|
|
||||||
* speed up sorting for Stream by @stonerl in https://github.com/yattee/yattee/pull/681
|
|
||||||
* faster chapter extraction by @stonerl in https://github.com/yattee/yattee/pull/682
|
|
||||||
* Invidious: add images to chapters by @stonerl in https://github.com/yattee/yattee/pull/685
|
|
||||||
* Improved Captions handling by @stonerl in https://github.com/yattee/yattee/pull/684
|
|
||||||
* Add User-Agent to request by @stonerl in https://github.com/yattee/yattee/pull/680
|
|
||||||
* MPV: speed up playback start by @stonerl in https://github.com/yattee/yattee/pull/689
|
|
||||||
* Advanced Settings: cache-pause-initial by @stonerl in https://github.com/yattee/yattee/pull/679
|
|
||||||
* Changed description for Format reordering by @stonerl in https://github.com/yattee/yattee/pull/677
|
|
||||||
* Add Chinese (Traditional) localization (by @rexcsk)
|
|
||||||
* Localization fixes
|
|
||||||
* Updated localizations
|
|
||||||
* Upgraded dependencies
|
|
||||||
* Fixed reported crash
|
|
||||||
* Other minor changes and improvements
|
|
||||||
|
|
||||||
**Big thanks to the current, past and future project contributors!**
|
* Tapping second time on search tab button focuses the input field and selects entered query text (iOS)
|
||||||
|
* Added Browsing setting "Keep channels with unwatched videos on top of subscriptions list"
|
||||||
|
* Improved buttons and layout on tvOS
|
||||||
|
* Fixed issue with trending categories (Invidious) not working when using non-English language
|
||||||
|
* Fixed issue with search query suggestions not being displayed properly in some languages
|
||||||
|
* Changed subscriptions page picker label from icon to text
|
||||||
|
* Views will display information if there is no videos to show instead of always showing placeholders
|
||||||
|
* Fixed AVPlayer issue with music mode playing video track
|
||||||
|
* Added remove context menu option for all types of recent items in Search
|
||||||
|
* Added advanced setting "Show video context menu options to force selected backend"
|
||||||
|
* Fixed reported crashes
|
||||||
|
|
||||||
|
* Improved Home
|
||||||
|
- Added menu with view options on iOS and toolbar buttons on macOS/tvOS
|
||||||
|
- Added Home Settings
|
||||||
|
- Moved settings from Browsing to Home Settings
|
||||||
|
- Enhanced Favorites management: select listing type and videos limit for each element
|
||||||
|
- Select listing type for History just like for Favorites
|
||||||
|
* Added view option to hide watched videos
|
||||||
|
* Added Browsing setting "Startup section"
|
||||||
|
* Added feed/channels list segmented picker in Subscriptions and moved view options menu on iOS
|
||||||
|
* Thumbnails in list view respect "Round corners" setting
|
||||||
|
* Added watching progress indicator to list view
|
||||||
|
* Moved "Show toggle watch status button" to History settings
|
||||||
|
* Removed "Rotate to portrait when exiting fullscreen" setting - it is instead automatically decided depending on device type
|
||||||
|
* Fixed channels view layout on tvOS
|
||||||
|
* Fixed channels and playlists navigation on tvOS
|
||||||
|
* Fixed issue where controls were not visible when music mode was enabled
|
||||||
|
* Fixed issue with closing Picture in Picture on macOS
|
||||||
|
* Fixed issue where playing video with AVPlayer would cause it to be immediately marked as watched
|
||||||
|
* Fixed issue with playlists view showing duplicated buttons when "Show cache status" is enabled
|
||||||
|
* Fixed issue where navigating to channel from list view in Playlists and Search would immediately go back
|
||||||
|
* Fixed issue where first URL would fail to open
|
||||||
|
|
||||||
|
* Added support for AVPlayer native system controls on iOS and macOS
|
||||||
|
- Use system features such as AirPlay, subtitles switching (Piped with HLS), text detection and copy and more
|
||||||
|
- Added Controls setting: "Use system controls with AVPlayer"
|
||||||
|
* Player rotates for landscape videos on entering full screen on iOS
|
||||||
|
- Player > Orientation setting: "Rotate when entering fullscreen on landscape video"
|
||||||
|
* Added Player > Playback setting: "Close video and player on end"
|
||||||
|
* Added reporting for opening stream in OSD for AVPlayer
|
||||||
|
* Fixed issue with opening channels and playlists links
|
||||||
|
* Fixed issues where controls/player layout could break (e.g., when going to background and back)
|
||||||
|
* Fixed issue where stream picker would show duplicate entries
|
||||||
|
* Fixed issue where search suggestions would show unnecessary bottom padding
|
||||||
|
* Fixed landscape channel sheet layout in player
|
||||||
|
* Fixed reported crashes
|
||||||
|
* Localization updates and fixes
|
||||||
|
* Other minor fixes and improvements
|
||||||
|
@ -17,13 +17,13 @@ extension String {
|
|||||||
|
|
||||||
var outputText = self
|
var outputText = self
|
||||||
|
|
||||||
for match in results.reversed() {
|
results.reversed().forEach { match in
|
||||||
for rangeIndex in (1 ..< match.numberOfRanges).reversed() {
|
(1 ..< match.numberOfRanges).reversed().forEach { rangeIndex in
|
||||||
let matchingGroup: String = (self as NSString).substring(with: match.range(at: rangeIndex))
|
let matchingGroup: String = (self as NSString).substring(with: match.range(at: rangeIndex))
|
||||||
let rangeBounds = match.range(at: rangeIndex)
|
let rangeBounds = match.range(at: rangeIndex)
|
||||||
|
|
||||||
guard let range = Range(rangeBounds, in: self) else {
|
guard let range = Range(rangeBounds, in: self) else {
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
let replacement = replacementStringClosure(matchingGroup) ?? matchingGroup
|
let replacement = replacementStringClosure(matchingGroup) ?? matchingGroup
|
||||||
|
|
||||||
|
@ -6,10 +6,8 @@ extension UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public class func swizzleHomeIndicatorProperty() {
|
public class func swizzleHomeIndicatorProperty() {
|
||||||
swizzle(
|
swizzle(origSelector: #selector(getter: UIViewController.prefersHomeIndicatorAutoHidden),
|
||||||
origSelector: #selector(getter: UIViewController.prefersHomeIndicatorAutoHidden),
|
|
||||||
withSelector: #selector(getter: UIViewController.swizzle_prefersHomeIndicatorAutoHidden),
|
withSelector: #selector(getter: UIViewController.swizzle_prefersHomeIndicatorAutoHidden),
|
||||||
forClass: UIViewController.self
|
forClass: UIViewController.self)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,11 +4,11 @@ extension URL {
|
|||||||
func byReplacingYatteeProtocol(with urlProtocol: String = "https") -> URL! {
|
func byReplacingYatteeProtocol(with urlProtocol: String = "https") -> URL! {
|
||||||
var urlAbsoluteString = absoluteString
|
var urlAbsoluteString = absoluteString
|
||||||
|
|
||||||
guard urlAbsoluteString.hasPrefix(Strings.yatteeProtocol) else {
|
guard urlAbsoluteString.hasPrefix(Constants.yatteeProtocol) else {
|
||||||
return self
|
return self
|
||||||
}
|
}
|
||||||
|
|
||||||
urlAbsoluteString = String(urlAbsoluteString.dropFirst(Strings.yatteeProtocol.count))
|
urlAbsoluteString = String(urlAbsoluteString.dropFirst(Constants.yatteeProtocol.count))
|
||||||
if absoluteString.contains("://") {
|
if absoluteString.contains("://") {
|
||||||
return URL(string: urlAbsoluteString)
|
return URL(string: urlAbsoluteString)
|
||||||
}
|
}
|
||||||
|
2
Gemfile
2
Gemfile
@ -1,6 +1,6 @@
|
|||||||
source "https://rubygems.org"
|
source "https://rubygems.org"
|
||||||
|
|
||||||
gem 'fastlane'
|
gem "fastlane"
|
||||||
|
|
||||||
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
|
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
|
||||||
eval_gemfile(plugins_path) if File.exist?(plugins_path)
|
eval_gemfile(plugins_path) if File.exist?(plugins_path)
|
||||||
|
163
Gemfile.lock
163
Gemfile.lock
@ -1,46 +1,43 @@
|
|||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
CFPropertyList (3.0.7)
|
CFPropertyList (3.0.6)
|
||||||
base64
|
|
||||||
nkf
|
|
||||||
rexml
|
rexml
|
||||||
addressable (2.8.7)
|
addressable (2.8.4)
|
||||||
public_suffix (>= 2.0.2, < 7.0)
|
public_suffix (>= 2.0.2, < 6.0)
|
||||||
artifactory (3.0.17)
|
artifactory (3.0.15)
|
||||||
atomos (0.1.3)
|
atomos (0.1.3)
|
||||||
aws-eventstream (1.3.2)
|
aws-eventstream (1.2.0)
|
||||||
aws-partitions (1.1072.0)
|
aws-partitions (1.769.0)
|
||||||
aws-sdk-core (3.220.2)
|
aws-sdk-core (3.173.1)
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
aws-partitions (~> 1, >= 1.992.0)
|
aws-partitions (~> 1, >= 1.651.0)
|
||||||
aws-sigv4 (~> 1.9)
|
aws-sigv4 (~> 1.5)
|
||||||
base64
|
|
||||||
jmespath (~> 1, >= 1.6.1)
|
jmespath (~> 1, >= 1.6.1)
|
||||||
aws-sdk-kms (1.99.0)
|
aws-sdk-kms (1.64.0)
|
||||||
aws-sdk-core (~> 3, >= 3.216.0)
|
aws-sdk-core (~> 3, >= 3.165.0)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.1)
|
||||||
aws-sdk-s3 (1.182.0)
|
aws-sdk-s3 (1.122.0)
|
||||||
aws-sdk-core (~> 3, >= 3.216.0)
|
aws-sdk-core (~> 3, >= 3.165.0)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.4)
|
||||||
aws-sigv4 (1.11.0)
|
aws-sigv4 (1.5.2)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
babosa (1.0.4)
|
babosa (1.0.4)
|
||||||
base64 (0.2.0)
|
|
||||||
claide (1.1.0)
|
claide (1.1.0)
|
||||||
colored (1.2)
|
colored (1.2)
|
||||||
colored2 (3.1.2)
|
colored2 (3.1.2)
|
||||||
commander (4.6.0)
|
commander (4.6.0)
|
||||||
highline (~> 2.0.0)
|
highline (~> 2.0.0)
|
||||||
declarative (0.0.20)
|
declarative (0.0.20)
|
||||||
digest-crc (0.7.0)
|
digest-crc (0.6.4)
|
||||||
rake (>= 12.0.0, < 14.0.0)
|
rake (>= 12.0.0, < 14.0.0)
|
||||||
domain_name (0.6.20240107)
|
domain_name (0.5.20190701)
|
||||||
|
unf (>= 0.0.5, < 1.0.0)
|
||||||
dotenv (2.8.1)
|
dotenv (2.8.1)
|
||||||
emoji_regex (3.2.3)
|
emoji_regex (3.2.3)
|
||||||
excon (0.112.0)
|
excon (0.99.0)
|
||||||
faraday (1.10.4)
|
faraday (1.10.3)
|
||||||
faraday-em_http (~> 1.0)
|
faraday-em_http (~> 1.0)
|
||||||
faraday-em_synchrony (~> 1.0)
|
faraday-em_synchrony (~> 1.0)
|
||||||
faraday-excon (~> 1.1)
|
faraday-excon (~> 1.1)
|
||||||
@ -59,24 +56,24 @@ GEM
|
|||||||
faraday-em_synchrony (1.0.0)
|
faraday-em_synchrony (1.0.0)
|
||||||
faraday-excon (1.1.0)
|
faraday-excon (1.1.0)
|
||||||
faraday-httpclient (1.0.1)
|
faraday-httpclient (1.0.1)
|
||||||
faraday-multipart (1.1.0)
|
faraday-multipart (1.0.4)
|
||||||
multipart-post (~> 2.0)
|
multipart-post (~> 2)
|
||||||
faraday-net_http (1.0.2)
|
faraday-net_http (1.0.1)
|
||||||
faraday-net_http_persistent (1.2.0)
|
faraday-net_http_persistent (1.2.0)
|
||||||
faraday-patron (1.0.0)
|
faraday-patron (1.0.0)
|
||||||
faraday-rack (1.0.0)
|
faraday-rack (1.0.0)
|
||||||
faraday-retry (1.0.3)
|
faraday-retry (1.0.3)
|
||||||
faraday_middleware (1.2.1)
|
faraday_middleware (1.2.0)
|
||||||
faraday (~> 1.0)
|
faraday (~> 1.0)
|
||||||
fastimage (2.4.0)
|
fastimage (2.2.7)
|
||||||
fastlane (2.227.0)
|
fastlane (2.213.0)
|
||||||
CFPropertyList (>= 2.3, < 4.0.0)
|
CFPropertyList (>= 2.3, < 4.0.0)
|
||||||
addressable (>= 2.8, < 3.0.0)
|
addressable (>= 2.8, < 3.0.0)
|
||||||
artifactory (~> 3.0)
|
artifactory (~> 3.0)
|
||||||
aws-sdk-s3 (~> 1.0)
|
aws-sdk-s3 (~> 1.0)
|
||||||
babosa (>= 1.0.3, < 2.0.0)
|
babosa (>= 1.0.3, < 2.0.0)
|
||||||
bundler (>= 1.12.0, < 3.0.0)
|
bundler (>= 1.12.0, < 3.0.0)
|
||||||
colored (~> 1.2)
|
colored
|
||||||
commander (~> 4.6)
|
commander (~> 4.6)
|
||||||
dotenv (>= 2.1.1, < 3.0.0)
|
dotenv (>= 2.1.1, < 3.0.0)
|
||||||
emoji_regex (>= 0.1, < 4.0)
|
emoji_regex (>= 0.1, < 4.0)
|
||||||
@ -85,38 +82,33 @@ GEM
|
|||||||
faraday-cookie_jar (~> 0.0.6)
|
faraday-cookie_jar (~> 0.0.6)
|
||||||
faraday_middleware (~> 1.0)
|
faraday_middleware (~> 1.0)
|
||||||
fastimage (>= 2.1.0, < 3.0.0)
|
fastimage (>= 2.1.0, < 3.0.0)
|
||||||
fastlane-sirp (>= 1.0.0)
|
|
||||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||||
google-apis-androidpublisher_v3 (~> 0.3)
|
google-apis-androidpublisher_v3 (~> 0.3)
|
||||||
google-apis-playcustomapp_v1 (~> 0.1)
|
google-apis-playcustomapp_v1 (~> 0.1)
|
||||||
google-cloud-env (>= 1.6.0, < 2.0.0)
|
|
||||||
google-cloud-storage (~> 1.31)
|
google-cloud-storage (~> 1.31)
|
||||||
highline (~> 2.0)
|
highline (~> 2.0)
|
||||||
http-cookie (~> 1.0.5)
|
|
||||||
json (< 3.0.0)
|
json (< 3.0.0)
|
||||||
jwt (>= 2.1.0, < 3)
|
jwt (>= 2.1.0, < 3)
|
||||||
mini_magick (>= 4.9.4, < 5.0.0)
|
mini_magick (>= 4.9.4, < 5.0.0)
|
||||||
multipart-post (>= 2.0.0, < 3.0.0)
|
multipart-post (>= 2.0.0, < 3.0.0)
|
||||||
naturally (~> 2.2)
|
naturally (~> 2.2)
|
||||||
optparse (>= 0.1.1, < 1.0.0)
|
optparse (~> 0.1.1)
|
||||||
plist (>= 3.1.0, < 4.0.0)
|
plist (>= 3.1.0, < 4.0.0)
|
||||||
rubyzip (>= 2.0.0, < 3.0.0)
|
rubyzip (>= 2.0.0, < 3.0.0)
|
||||||
security (= 0.1.5)
|
security (= 0.1.3)
|
||||||
simctl (~> 1.6.3)
|
simctl (~> 1.6.3)
|
||||||
terminal-notifier (>= 2.0.0, < 3.0.0)
|
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||||
terminal-table (~> 3)
|
terminal-table (>= 1.4.5, < 2.0.0)
|
||||||
tty-screen (>= 0.6.3, < 1.0.0)
|
tty-screen (>= 0.6.3, < 1.0.0)
|
||||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||||
word_wrap (~> 1.0.0)
|
word_wrap (~> 1.0.0)
|
||||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||||
xcpretty (~> 0.4.0)
|
xcpretty (~> 0.3.0)
|
||||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
xcpretty-travis-formatter (>= 0.0.3)
|
||||||
fastlane-sirp (1.0.0)
|
|
||||||
sysrandom (~> 1.0)
|
|
||||||
gh_inspector (1.1.3)
|
gh_inspector (1.1.3)
|
||||||
google-apis-androidpublisher_v3 (0.54.0)
|
google-apis-androidpublisher_v3 (0.42.0)
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
google-apis-core (>= 0.11.0, < 2.a)
|
||||||
google-apis-core (0.11.3)
|
google-apis-core (0.11.0)
|
||||||
addressable (~> 2.5, >= 2.5.1)
|
addressable (~> 2.5, >= 2.5.1)
|
||||||
googleauth (>= 0.16.2, < 2.a)
|
googleauth (>= 0.16.2, < 2.a)
|
||||||
httpclient (>= 2.8.1, < 3.a)
|
httpclient (>= 2.8.1, < 3.a)
|
||||||
@ -124,65 +116,64 @@ GEM
|
|||||||
representable (~> 3.0)
|
representable (~> 3.0)
|
||||||
retriable (>= 2.0, < 4.a)
|
retriable (>= 2.0, < 4.a)
|
||||||
rexml
|
rexml
|
||||||
|
webrick
|
||||||
google-apis-iamcredentials_v1 (0.17.0)
|
google-apis-iamcredentials_v1 (0.17.0)
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
google-apis-core (>= 0.11.0, < 2.a)
|
||||||
google-apis-playcustomapp_v1 (0.13.0)
|
google-apis-playcustomapp_v1 (0.13.0)
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
google-apis-core (>= 0.11.0, < 2.a)
|
||||||
google-apis-storage_v1 (0.31.0)
|
google-apis-storage_v1 (0.19.0)
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
google-apis-core (>= 0.9.0, < 2.a)
|
||||||
google-cloud-core (1.8.0)
|
google-cloud-core (1.6.0)
|
||||||
google-cloud-env (>= 1.0, < 3.a)
|
google-cloud-env (~> 1.0)
|
||||||
google-cloud-errors (~> 1.0)
|
google-cloud-errors (~> 1.0)
|
||||||
google-cloud-env (1.6.0)
|
google-cloud-env (1.6.0)
|
||||||
faraday (>= 0.17.3, < 3.0)
|
faraday (>= 0.17.3, < 3.0)
|
||||||
google-cloud-errors (1.5.0)
|
google-cloud-errors (1.3.1)
|
||||||
google-cloud-storage (1.47.0)
|
google-cloud-storage (1.44.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
digest-crc (~> 0.4)
|
digest-crc (~> 0.4)
|
||||||
google-apis-iamcredentials_v1 (~> 0.1)
|
google-apis-iamcredentials_v1 (~> 0.1)
|
||||||
google-apis-storage_v1 (~> 0.31.0)
|
google-apis-storage_v1 (~> 0.19.0)
|
||||||
google-cloud-core (~> 1.6)
|
google-cloud-core (~> 1.6)
|
||||||
googleauth (>= 0.16.2, < 2.a)
|
googleauth (>= 0.16.2, < 2.a)
|
||||||
mini_mime (~> 1.0)
|
mini_mime (~> 1.0)
|
||||||
googleauth (1.8.1)
|
googleauth (1.5.2)
|
||||||
faraday (>= 0.17.3, < 3.a)
|
faraday (>= 0.17.3, < 3.a)
|
||||||
jwt (>= 1.4, < 3.0)
|
jwt (>= 1.4, < 3.0)
|
||||||
|
memoist (~> 0.16)
|
||||||
multi_json (~> 1.11)
|
multi_json (~> 1.11)
|
||||||
os (>= 0.9, < 2.0)
|
os (>= 0.9, < 2.0)
|
||||||
signet (>= 0.16, < 2.a)
|
signet (>= 0.16, < 2.a)
|
||||||
highline (2.0.3)
|
highline (2.0.3)
|
||||||
http-cookie (1.0.8)
|
http-cookie (1.0.5)
|
||||||
domain_name (~> 0.5)
|
domain_name (~> 0.5)
|
||||||
httpclient (2.9.0)
|
httpclient (2.8.3)
|
||||||
mutex_m
|
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
json (2.10.2)
|
json (2.6.3)
|
||||||
jwt (2.10.1)
|
jwt (2.7.0)
|
||||||
base64
|
memoist (0.16.2)
|
||||||
mini_magick (4.13.2)
|
mini_magick (4.12.0)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.2)
|
||||||
multi_json (1.15.0)
|
multi_json (1.15.0)
|
||||||
multipart-post (2.4.1)
|
multipart-post (2.3.0)
|
||||||
mutex_m (0.3.0)
|
nanaimo (0.3.0)
|
||||||
nanaimo (0.4.0)
|
|
||||||
naturally (2.2.1)
|
naturally (2.2.1)
|
||||||
nkf (0.2.0)
|
optparse (0.1.1)
|
||||||
optparse (0.6.0)
|
|
||||||
os (1.1.4)
|
os (1.1.4)
|
||||||
plist (3.7.2)
|
plist (3.7.0)
|
||||||
public_suffix (6.0.1)
|
public_suffix (5.0.1)
|
||||||
rake (13.2.1)
|
rake (13.0.6)
|
||||||
representable (3.2.0)
|
representable (3.2.0)
|
||||||
declarative (< 0.1.0)
|
declarative (< 0.1.0)
|
||||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||||
uber (< 0.2.0)
|
uber (< 0.2.0)
|
||||||
retriable (3.1.2)
|
retriable (3.1.2)
|
||||||
rexml (3.4.1)
|
rexml (3.2.5)
|
||||||
rouge (3.28.0)
|
rouge (2.0.7)
|
||||||
ruby2_keywords (0.0.5)
|
ruby2_keywords (0.0.5)
|
||||||
rubyzip (2.4.1)
|
rubyzip (2.3.2)
|
||||||
security (0.1.5)
|
security (0.1.3)
|
||||||
signet (0.19.0)
|
signet (0.17.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
faraday (>= 0.17.5, < 3.a)
|
faraday (>= 0.17.5, < 3.a)
|
||||||
jwt (>= 1.5, < 3.0)
|
jwt (>= 1.5, < 3.0)
|
||||||
@ -190,37 +181,37 @@ GEM
|
|||||||
simctl (1.6.10)
|
simctl (1.6.10)
|
||||||
CFPropertyList
|
CFPropertyList
|
||||||
naturally
|
naturally
|
||||||
sysrandom (1.0.5)
|
|
||||||
terminal-notifier (2.0.0)
|
terminal-notifier (2.0.0)
|
||||||
terminal-table (3.0.2)
|
terminal-table (1.8.0)
|
||||||
unicode-display_width (>= 1.1.1, < 3)
|
unicode-display_width (~> 1.1, >= 1.1.1)
|
||||||
trailblazer-option (0.1.2)
|
trailblazer-option (0.1.2)
|
||||||
tty-cursor (0.7.1)
|
tty-cursor (0.7.1)
|
||||||
tty-screen (0.8.2)
|
tty-screen (0.8.1)
|
||||||
tty-spinner (0.9.3)
|
tty-spinner (0.9.3)
|
||||||
tty-cursor (~> 0.7)
|
tty-cursor (~> 0.7)
|
||||||
uber (0.1.0)
|
uber (0.1.0)
|
||||||
unicode-display_width (2.6.0)
|
unf (0.1.4)
|
||||||
|
unf_ext
|
||||||
|
unf_ext (0.0.8.2)
|
||||||
|
unicode-display_width (1.8.0)
|
||||||
|
webrick (1.8.1)
|
||||||
word_wrap (1.0.0)
|
word_wrap (1.0.0)
|
||||||
xcodeproj (1.27.0)
|
xcodeproj (1.22.0)
|
||||||
CFPropertyList (>= 2.3.3, < 4.0)
|
CFPropertyList (>= 2.3.3, < 4.0)
|
||||||
atomos (~> 0.1.3)
|
atomos (~> 0.1.3)
|
||||||
claide (>= 1.0.2, < 2.0)
|
claide (>= 1.0.2, < 2.0)
|
||||||
colored2 (~> 3.1)
|
colored2 (~> 3.1)
|
||||||
nanaimo (~> 0.4.0)
|
nanaimo (~> 0.3.0)
|
||||||
rexml (>= 3.3.6, < 4.0)
|
rexml (~> 3.2.4)
|
||||||
xcpretty (0.4.0)
|
xcpretty (0.3.0)
|
||||||
rouge (~> 3.28.0)
|
rouge (~> 2.0.7)
|
||||||
xcpretty-travis-formatter (1.0.1)
|
xcpretty-travis-formatter (1.0.1)
|
||||||
xcpretty (~> 0.2, >= 0.0.7)
|
xcpretty (~> 0.2, >= 0.0.7)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
arm64-darwin-21
|
arm64-darwin-21
|
||||||
arm64-darwin-23
|
|
||||||
arm64-darwin-24
|
|
||||||
x86_64-darwin-19
|
x86_64-darwin-19
|
||||||
x86_64-darwin-20
|
x86_64-darwin-20
|
||||||
x86_64-darwin-21
|
|
||||||
x86_64-linux
|
x86_64-linux
|
||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
|
@ -60,10 +60,6 @@ struct Account: Defaults.Serializable, Hashable, Identifiable {
|
|||||||
instanceID.isNil
|
instanceID.isNil
|
||||||
}
|
}
|
||||||
|
|
||||||
var isPublicAddedToCustom: Bool {
|
|
||||||
InstancesModel.shared.findByURLString(urlString) != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
guard !isPublic else {
|
guard !isPublic else {
|
||||||
return name
|
return name
|
||||||
|
@ -10,28 +10,11 @@ struct AccountsBridge: Defaults.Bridge {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the urlString to check for embedded username and password
|
|
||||||
var sanitizedUrlString = value.urlString
|
|
||||||
if var urlComponents = URLComponents(string: value.urlString) {
|
|
||||||
if let user = urlComponents.user, let password = urlComponents.password {
|
|
||||||
// Sanitize the embedded username and password
|
|
||||||
let sanitizedUser = user.addingPercentEncoding(withAllowedCharacters: .urlUserAllowed) ?? user
|
|
||||||
let sanitizedPassword = password.addingPercentEncoding(withAllowedCharacters: .urlPasswordAllowed) ?? password
|
|
||||||
|
|
||||||
// Update the URL components with sanitized credentials
|
|
||||||
urlComponents.user = sanitizedUser
|
|
||||||
urlComponents.password = sanitizedPassword
|
|
||||||
|
|
||||||
// Reconstruct the sanitized URL
|
|
||||||
sanitizedUrlString = urlComponents.string ?? value.urlString
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"id": value.id,
|
"id": value.id,
|
||||||
"instanceID": value.instanceID ?? "",
|
"instanceID": value.instanceID ?? "",
|
||||||
"name": value.name,
|
"name": value.name,
|
||||||
"apiURL": sanitizedUrlString,
|
"apiURL": value.urlString,
|
||||||
"username": value.username,
|
"username": value.username,
|
||||||
"password": value.password ?? ""
|
"password": value.password ?? ""
|
||||||
]
|
]
|
||||||
|
@ -64,10 +64,6 @@ final class AccountsModel: ObservableObject {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func find(_ id: Account.ID) -> Account? {
|
|
||||||
all.first { $0.id == id }
|
|
||||||
}
|
|
||||||
|
|
||||||
func configureAccount() {
|
func configureAccount() {
|
||||||
if let account = lastUsed ??
|
if let account = lastUsed ??
|
||||||
InstancesModel.shared.lastUsed?.anonymousAccount ??
|
InstancesModel.shared.lastUsed?.anonymousAccount ??
|
||||||
@ -112,8 +108,8 @@ final class AccountsModel: ObservableObject {
|
|||||||
Defaults[.accounts].first { $0.id == id }
|
Defaults[.accounts].first { $0.id == id }
|
||||||
}
|
}
|
||||||
|
|
||||||
static func add(instance: Instance, id: String? = UUID().uuidString, name: String, username: String, password: String) -> Account {
|
static func add(instance: Instance, name: String, username: String, password: String) -> Account {
|
||||||
let account = Account(id: id, instanceID: instance.id, name: name, urlString: instance.apiURLString)
|
let account = Account(instanceID: instance.id, name: name, urlString: instance.apiURLString)
|
||||||
Defaults[.accounts].append(account)
|
Defaults[.accounts].append(account)
|
||||||
|
|
||||||
setCredentials(account, username: username, password: password)
|
setCredentials(account, username: username, password: password)
|
||||||
|
@ -10,16 +10,14 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
|
|||||||
let apiURLString: String
|
let apiURLString: String
|
||||||
var frontendURL: String?
|
var frontendURL: String?
|
||||||
var proxiesVideos: Bool
|
var proxiesVideos: Bool
|
||||||
var invidiousCompanion: Bool
|
|
||||||
|
|
||||||
init(app: VideosApp, id: String? = nil, name: String? = nil, apiURLString: String, frontendURL: String? = nil, proxiesVideos: Bool = false, invidiousCompanion: Bool = false) {
|
init(app: VideosApp, id: String? = nil, name: String? = nil, apiURLString: String, frontendURL: String? = nil, proxiesVideos: Bool = false) {
|
||||||
self.app = app
|
self.app = app
|
||||||
self.id = id ?? UUID().uuidString
|
self.id = id ?? UUID().uuidString
|
||||||
self.name = name ?? app.rawValue
|
self.name = name ?? app.rawValue
|
||||||
self.apiURLString = apiURLString
|
self.apiURLString = apiURLString
|
||||||
self.frontendURL = frontendURL
|
self.frontendURL = frontendURL
|
||||||
self.proxiesVideos = proxiesVideos
|
self.proxiesVideos = proxiesVideos
|
||||||
self.invidiousCompanion = invidiousCompanion
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var apiURL: URL! {
|
var apiURL: URL! {
|
||||||
@ -70,8 +68,4 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
|
|||||||
func hash(into hasher: inout Hasher) {
|
func hash(into hasher: inout Hasher) {
|
||||||
hasher.combine(apiURL)
|
hasher.combine(apiURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
var accounts: [Account] {
|
|
||||||
AccountsModel.shared.all.filter { $0.instanceID == id }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -16,8 +16,7 @@ struct InstancesBridge: Defaults.Bridge {
|
|||||||
"name": value.name,
|
"name": value.name,
|
||||||
"apiURL": value.apiURLString,
|
"apiURL": value.apiURLString,
|
||||||
"frontendURL": value.frontendURL ?? "",
|
"frontendURL": value.frontendURL ?? "",
|
||||||
"proxiesVideos": value.proxiesVideos ? "true" : "false",
|
"proxiesVideos": value.proxiesVideos ? "true" : "false"
|
||||||
"invidiousCompanion": value.invidiousCompanion ? "true" : "false"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,8 +33,7 @@ struct InstancesBridge: Defaults.Bridge {
|
|||||||
let name = object["name"] ?? ""
|
let name = object["name"] ?? ""
|
||||||
let frontendURL: String? = object["frontendURL"]!.isEmpty ? nil : object["frontendURL"]
|
let frontendURL: String? = object["frontendURL"]!.isEmpty ? nil : object["frontendURL"]
|
||||||
let proxiesVideos = object["proxiesVideos"] == "true"
|
let proxiesVideos = object["proxiesVideos"] == "true"
|
||||||
let invidiousCompanion = object["invidiousCompanion"] == "true"
|
|
||||||
|
|
||||||
return Instance(app: app, id: id, name: name, apiURLString: apiURL, frontendURL: frontendURL, proxiesVideos: proxiesVideos, invidiousCompanion: invidiousCompanion)
|
return Instance(app: app, id: id, name: name, apiURLString: apiURL, frontendURL: frontendURL, proxiesVideos: proxiesVideos)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -32,33 +32,19 @@ final class InstancesModel: ObservableObject {
|
|||||||
return Defaults[.instances].first { $0.id == id }
|
return Defaults[.instances].first { $0.id == id }
|
||||||
}
|
}
|
||||||
|
|
||||||
func findByURLString(_ urlString: String?) -> Instance? {
|
|
||||||
guard let urlString else { return nil }
|
|
||||||
|
|
||||||
return Defaults[.instances].first { $0.apiURLString == urlString }
|
|
||||||
}
|
|
||||||
|
|
||||||
func accounts(_ id: Instance.ID?) -> [Account] {
|
func accounts(_ id: Instance.ID?) -> [Account] {
|
||||||
Defaults[.accounts].filter { $0.instanceID == id }
|
Defaults[.accounts].filter { $0.instanceID == id }
|
||||||
}
|
}
|
||||||
|
|
||||||
func add(id: String? = UUID().uuidString, app: VideosApp, name: String, url: String) -> Instance {
|
func add(app: VideosApp, name: String, url: String) -> Instance {
|
||||||
let instance = Instance(
|
let instance = Instance(
|
||||||
app: app, id: id, name: name, apiURLString: standardizedURL(url)
|
app: app, id: UUID().uuidString, name: name, apiURLString: standardizedURL(url)
|
||||||
)
|
)
|
||||||
Defaults[.instances].append(instance)
|
Defaults[.instances].append(instance)
|
||||||
|
|
||||||
return instance
|
return instance
|
||||||
}
|
}
|
||||||
|
|
||||||
func insert(id: String? = UUID().uuidString, app: VideosApp, name: String, url: String) -> Instance {
|
|
||||||
if let instance = Defaults[.instances].first(where: { $0.apiURL.absoluteString == standardizedURL(url) }) {
|
|
||||||
return instance
|
|
||||||
}
|
|
||||||
|
|
||||||
return add(id: id, app: app, name: name, url: url)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setFrontendURL(_ instance: Instance, _ url: String) {
|
func setFrontendURL(_ instance: Instance, _ url: String) {
|
||||||
if let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) {
|
if let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) {
|
||||||
var instance = Defaults[.instances][index]
|
var instance = Defaults[.instances][index]
|
||||||
@ -79,17 +65,6 @@ final class InstancesModel: ObservableObject {
|
|||||||
Defaults[.instances][index] = instance
|
Defaults[.instances][index] = instance
|
||||||
}
|
}
|
||||||
|
|
||||||
func setInvidiousCompanion(_ instance: Instance, _ invidiousCompanion: Bool) {
|
|
||||||
guard let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var instance = Defaults[.instances][index]
|
|
||||||
instance.invidiousCompanion = invidiousCompanion
|
|
||||||
|
|
||||||
Defaults[.instances][index] = instance
|
|
||||||
}
|
|
||||||
|
|
||||||
func remove(_ instance: Instance) {
|
func remove(_ instance: Instance) {
|
||||||
let accounts = accounts(instance.id)
|
let accounts = accounts(instance.id)
|
||||||
if let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) {
|
if let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) {
|
||||||
@ -101,7 +76,8 @@ final class InstancesModel: ObservableObject {
|
|||||||
func standardizedURL(_ url: String) -> String {
|
func standardizedURL(_ url: String) -> String {
|
||||||
if url.count > 7, url.last == "/" {
|
if url.count > 7, url.last == "/" {
|
||||||
return String(url.dropLast())
|
return String(url.dropLast())
|
||||||
}
|
} else {
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -65,11 +65,9 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
|||||||
|
|
||||||
if type == "channel" {
|
if type == "channel" {
|
||||||
return ContentItem(channel: self.extractChannel(from: json))
|
return ContentItem(channel: self.extractChannel(from: json))
|
||||||
}
|
} else if type == "playlist" {
|
||||||
if type == "playlist" {
|
|
||||||
return ContentItem(playlist: self.extractChannelPlaylist(from: json))
|
return ContentItem(playlist: self.extractChannelPlaylist(from: json))
|
||||||
}
|
} else if type == "video" {
|
||||||
if type == "video" {
|
|
||||||
return ContentItem(video: self.extractVideo(from: json))
|
return ContentItem(video: self.extractVideo(from: json))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,7 +79,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
|||||||
|
|
||||||
configureTransformer(pathPattern("search/suggestions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [String] in
|
configureTransformer(pathPattern("search/suggestions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [String] in
|
||||||
if let suggestions = content.json.dictionaryValue["suggestions"] {
|
if let suggestions = content.json.dictionaryValue["suggestions"] {
|
||||||
return suggestions.arrayValue.map(\.stringValue).map(\.replacingHTMLEntities)
|
return suggestions.arrayValue.map { $0.stringValue.replacingHTMLEntities }
|
||||||
}
|
}
|
||||||
|
|
||||||
return []
|
return []
|
||||||
@ -123,7 +121,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
|||||||
content.json.dictionaryValue["videos"]?.arrayValue.map(self.extractVideo) ?? []
|
content.json.dictionaryValue["videos"]?.arrayValue.map(self.extractVideo) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
for type in ["latest", "playlists", "streams", "shorts", "channels", "videos", "releases", "podcasts"] {
|
["latest", "playlists", "streams", "shorts", "channels", "videos"].forEach { type in
|
||||||
configureTransformer(pathPattern("channels/*/\(type)"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPage in
|
configureTransformer(pathPattern("channels/*/\(type)"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPage in
|
||||||
self.extractChannelPage(from: content.json)
|
self.extractChannelPage(from: content.json)
|
||||||
}
|
}
|
||||||
@ -247,27 +245,27 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func feed(_ page: Int?) -> Resource? {
|
func feed(_ page: Int?) -> Resource? {
|
||||||
resourceWithAuthCheck(baseURL: account.url, path: "\(Self.basePath)/auth/feed")
|
resource(baseURL: account.url, path: "\(Self.basePath)/auth/feed")
|
||||||
.withParam("page", String(page ?? 1))
|
.withParam("page", String(page ?? 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
var feed: Resource? {
|
var feed: Resource? {
|
||||||
resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/feed"))
|
resource(baseURL: account.url, path: basePathAppending("auth/feed"))
|
||||||
}
|
}
|
||||||
|
|
||||||
var subscriptions: Resource? {
|
var subscriptions: Resource? {
|
||||||
resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
|
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
|
||||||
resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||||
.child(channelID)
|
.child(channelID)
|
||||||
.request(.post)
|
.request(.post)
|
||||||
.onCompletion { _ in onCompletion() }
|
.onCompletion { _ in onCompletion() }
|
||||||
}
|
}
|
||||||
|
|
||||||
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void) {
|
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void) {
|
||||||
resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||||
.child(channelID)
|
.child(channelID)
|
||||||
.request(.delete)
|
.request(.delete)
|
||||||
.onCompletion { _ in onCompletion() }
|
.onCompletion { _ in onCompletion() }
|
||||||
@ -308,11 +306,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/playlists"))
|
return resource(baseURL: account.url, path: basePathAppending("auth/playlists"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func playlist(_ id: String) -> Resource? {
|
func playlist(_ id: String) -> Resource? {
|
||||||
resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/playlists/\(id)"))
|
resource(baseURL: account.url, path: basePathAppending("auth/playlists/\(id)"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func playlistVideos(_ id: String) -> Resource? {
|
func playlistVideos(_ id: String) -> Resource? {
|
||||||
@ -445,9 +443,6 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
|||||||
|
|
||||||
urlComponents.scheme = instanceURLComponents.scheme
|
urlComponents.scheme = instanceURLComponents.scheme
|
||||||
urlComponents.host = instanceURLComponents.host
|
urlComponents.host = instanceURLComponents.host
|
||||||
urlComponents.user = instanceURLComponents.user
|
|
||||||
urlComponents.password = instanceURLComponents.password
|
|
||||||
urlComponents.port = instanceURLComponents.port
|
|
||||||
|
|
||||||
guard let url = urlComponents.url else {
|
guard let url = urlComponents.url else {
|
||||||
return nil
|
return nil
|
||||||
@ -498,14 +493,14 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
|||||||
indexID: indexID,
|
indexID: indexID,
|
||||||
live: json["liveNow"].boolValue,
|
live: json["liveNow"].boolValue,
|
||||||
upcoming: json["isUpcoming"].boolValue,
|
upcoming: json["isUpcoming"].boolValue,
|
||||||
short: length <= Video.shortLength && length != 0.0,
|
short: length <= Video.shortLength,
|
||||||
publishedAt: publishedAt,
|
publishedAt: publishedAt,
|
||||||
likes: json["likeCount"].int,
|
likes: json["likeCount"].int,
|
||||||
dislikes: json["dislikeCount"].int,
|
dislikes: json["dislikeCount"].int,
|
||||||
keywords: json["keywords"].arrayValue.compactMap { $0.string },
|
keywords: json["keywords"].arrayValue.compactMap { $0.string },
|
||||||
streams: extractStreams(from: json),
|
streams: extractStreams(from: json),
|
||||||
related: extractRelated(from: json),
|
related: extractRelated(from: json),
|
||||||
chapters: createChapters(from: description, thumbnails: json),
|
chapters: extractChapters(from: description),
|
||||||
captions: extractCaptions(from: json)
|
captions: extractCaptions(from: json)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -556,30 +551,6 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determines if the request requires Basic Auth credentials to be removed
|
|
||||||
private func needsBasicAuthRemoval(for path: String) -> Bool {
|
|
||||||
return path.hasPrefix("\(Self.basePath)/auth/")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Creates a resource URL with consideration for removing Basic Auth credentials
|
|
||||||
private func createResourceURL(baseURL: URL, path: String) -> URL {
|
|
||||||
var resourceURL = baseURL
|
|
||||||
|
|
||||||
// Remove Basic Auth credentials if required
|
|
||||||
if needsBasicAuthRemoval(for: path), var urlComponents = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) {
|
|
||||||
urlComponents.user = nil
|
|
||||||
urlComponents.password = nil
|
|
||||||
resourceURL = urlComponents.url ?? baseURL
|
|
||||||
}
|
|
||||||
|
|
||||||
return resourceURL.appendingPathComponent(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func resourceWithAuthCheck(baseURL: URL, path: String) -> Resource {
|
|
||||||
let sanitizedURL = createResourceURL(baseURL: baseURL, path: path)
|
|
||||||
return super.resource(absoluteURL: sanitizedURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func extractThumbnails(from details: JSON) -> [Thumbnail] {
|
private func extractThumbnails(from details: JSON) -> [Thumbnail] {
|
||||||
details["videoThumbnails"].arrayValue.compactMap { json in
|
details["videoThumbnails"].arrayValue.compactMap { json in
|
||||||
guard let url = json["url"].url,
|
guard let url = json["url"].url,
|
||||||
@ -590,41 +561,18 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Some instances are not configured properly and return thumbnail links
|
// some of instances are not configured properly and return thumbnails links
|
||||||
// with an incorrect scheme or a missing port.
|
// with incorrect scheme
|
||||||
components.scheme = accountUrlComponents.scheme
|
components.scheme = accountUrlComponents.scheme
|
||||||
components.port = accountUrlComponents.port
|
|
||||||
|
|
||||||
// If basic HTTP authentication is used,
|
|
||||||
// the username and password need to be prepended to the URL.
|
|
||||||
components.user = accountUrlComponents.user
|
|
||||||
components.password = accountUrlComponents.password
|
|
||||||
|
|
||||||
guard let thumbnailUrl = components.url else {
|
guard let thumbnailUrl = components.url else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
print("Final thumbnail URL: \(thumbnailUrl)")
|
|
||||||
|
|
||||||
return Thumbnail(url: thumbnailUrl, quality: .init(rawValue: quality)!)
|
return Thumbnail(url: thumbnailUrl, quality: .init(rawValue: quality)!)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createChapters(from description: String, thumbnails: JSON) -> [Chapter] {
|
|
||||||
var chapters = extractChapters(from: description)
|
|
||||||
|
|
||||||
if !chapters.isEmpty {
|
|
||||||
let thumbnailsData = extractThumbnails(from: thumbnails)
|
|
||||||
let thumbnailURL = thumbnailsData.first { $0.quality == .medium }?.url
|
|
||||||
|
|
||||||
for chapter in chapters.indices {
|
|
||||||
if let url = thumbnailURL {
|
|
||||||
chapters[chapter].image = url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return chapters
|
|
||||||
}
|
|
||||||
|
|
||||||
private static var contentItemsKeys = ["items", "videos", "latestVideos", "playlists", "relatedChannels"]
|
private static var contentItemsKeys = ["items", "videos", "latestVideos", "playlists", "relatedChannels"]
|
||||||
|
|
||||||
private func extractChannelPage(from json: JSON, forceNotLast: Bool = false) -> ChannelPage {
|
private func extractChannelPage(from json: JSON, forceNotLast: Bool = false) -> ChannelPage {
|
||||||
@ -655,29 +603,21 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
|||||||
if json["liveNow"].boolValue {
|
if json["liveNow"].boolValue {
|
||||||
return hls
|
return hls
|
||||||
}
|
}
|
||||||
let videoId = json["videoId"].stringValue
|
|
||||||
|
|
||||||
return extractFormatStreams(from: json["formatStreams"].arrayValue, videoId: videoId) +
|
return extractFormatStreams(from: json["formatStreams"].arrayValue) +
|
||||||
extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue, videoId: videoId) +
|
extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue) +
|
||||||
hls
|
hls
|
||||||
}
|
}
|
||||||
|
|
||||||
private func extractFormatStreams(from streams: [JSON], videoId: String?) -> [Stream] {
|
private func extractFormatStreams(from streams: [JSON]) -> [Stream] {
|
||||||
streams.compactMap { stream in
|
streams.compactMap { stream in
|
||||||
guard let streamURL = stream["url"].url else {
|
guard let streamURL = stream["url"].url else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let finalURL: URL
|
|
||||||
if let videoId, let itag = stream["itag"].string, account.instance.invidiousCompanion {
|
|
||||||
let companionURLString = "\(account.instance.apiURLString)/latest_version?id=\(videoId)&itag=\(itag)"
|
|
||||||
finalURL = URL(string: companionURLString) ?? streamURL
|
|
||||||
} else {
|
|
||||||
finalURL = streamURL
|
|
||||||
}
|
|
||||||
|
|
||||||
return SingleAssetStream(
|
return SingleAssetStream(
|
||||||
instance: account.instance,
|
instance: account.instance,
|
||||||
avAsset: AVURLAsset(url: finalURL),
|
avAsset: AVURLAsset(url: streamURL),
|
||||||
resolution: Stream.Resolution.from(resolution: stream["resolution"].string ?? ""),
|
resolution: Stream.Resolution.from(resolution: stream["resolution"].string ?? ""),
|
||||||
kind: .stream,
|
kind: .stream,
|
||||||
encoding: stream["encoding"].string ?? ""
|
encoding: stream["encoding"].string ?? ""
|
||||||
@ -685,7 +625,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func extractAdaptiveFormats(from streams: [JSON], videoId: String?) -> [Stream] {
|
private func extractAdaptiveFormats(from streams: [JSON]) -> [Stream] {
|
||||||
let audioStreams = streams
|
let audioStreams = streams
|
||||||
.filter { $0["type"].stringValue.starts(with: "audio/mp4") }
|
.filter { $0["type"].stringValue.starts(with: "audio/mp4") }
|
||||||
.sorted {
|
.sorted {
|
||||||
@ -700,35 +640,19 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
|||||||
|
|
||||||
return videoStreams.compactMap { videoStream in
|
return videoStreams.compactMap { videoStream in
|
||||||
guard let audioAssetURL = audioStream["url"].url,
|
guard let audioAssetURL = audioStream["url"].url,
|
||||||
let videoAssetURL = videoStream["url"].url,
|
let videoAssetURL = videoStream["url"].url
|
||||||
let audioItag = audioStream["itag"].string,
|
|
||||||
let videoItag = videoStream["itag"].string
|
|
||||||
else {
|
else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let finalAudioURL: URL
|
|
||||||
let finalVideoURL: URL
|
|
||||||
|
|
||||||
if let videoId, account.instance.invidiousCompanion {
|
|
||||||
let audioCompanionURLString = "\(account.instance.apiURLString)/latest_version?id=\(videoId)&itag=\(audioItag)"
|
|
||||||
let videoCompanionURLString = "\(account.instance.apiURLString)/latest_version?id=\(videoId)&itag=\(videoItag)"
|
|
||||||
finalAudioURL = URL(string: audioCompanionURLString) ?? audioAssetURL
|
|
||||||
finalVideoURL = URL(string: videoCompanionURLString) ?? videoAssetURL
|
|
||||||
} else {
|
|
||||||
finalAudioURL = audioAssetURL
|
|
||||||
finalVideoURL = videoAssetURL
|
|
||||||
}
|
|
||||||
|
|
||||||
return Stream(
|
return Stream(
|
||||||
instance: account.instance,
|
instance: account.instance,
|
||||||
audioAsset: AVURLAsset(url: finalAudioURL),
|
audioAsset: AVURLAsset(url: audioAssetURL),
|
||||||
videoAsset: AVURLAsset(url: finalVideoURL),
|
videoAsset: AVURLAsset(url: videoAssetURL),
|
||||||
resolution: Stream.Resolution.from(resolution: videoStream["resolution"].stringValue),
|
resolution: Stream.Resolution.from(resolution: videoStream["resolution"].stringValue),
|
||||||
kind: .adaptive,
|
kind: .adaptive,
|
||||||
encoding: videoStream["encoding"].string,
|
encoding: videoStream["encoding"].string,
|
||||||
videoFormat: videoStream["type"].string,
|
videoFormat: videoStream["type"].string
|
||||||
bitrate: videoStream["bitrate"].int,
|
|
||||||
requestRange: videoStream["init"].string ?? videoStream["index"].string
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -765,8 +689,6 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
|||||||
let author = details["author"]?.string ?? ""
|
let author = details["author"]?.string ?? ""
|
||||||
let channelId = details["authorId"]?.string ?? UUID().uuidString
|
let channelId = details["authorId"]?.string ?? UUID().uuidString
|
||||||
let authorAvatarURL = details["authorThumbnails"]?.arrayValue.last?.dictionaryValue["url"]?.string ?? ""
|
let authorAvatarURL = details["authorThumbnails"]?.arrayValue.last?.dictionaryValue["url"]?.string ?? ""
|
||||||
let htmlContent = details["contentHtml"]?.string ?? ""
|
|
||||||
let decodedContent = decodeHtml(htmlContent)
|
|
||||||
return Comment(
|
return Comment(
|
||||||
id: UUID().uuidString,
|
id: UUID().uuidString,
|
||||||
author: author,
|
author: author,
|
||||||
@ -775,25 +697,12 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
|||||||
pinned: false,
|
pinned: false,
|
||||||
hearted: false,
|
hearted: false,
|
||||||
likeCount: details["likeCount"]?.int ?? 0,
|
likeCount: details["likeCount"]?.int ?? 0,
|
||||||
text: decodedContent,
|
text: details["content"]?.string ?? "",
|
||||||
repliesPage: details["replies"]?.dictionaryValue["continuation"]?.string,
|
repliesPage: details["replies"]?.dictionaryValue["continuation"]?.string,
|
||||||
channel: Channel(app: .invidious, id: channelId, name: author)
|
channel: Channel(app: .invidious, id: channelId, name: author)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func decodeHtml(_ htmlEncodedString: String) -> String {
|
|
||||||
if let data = htmlEncodedString.data(using: .utf8) {
|
|
||||||
let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
|
|
||||||
.documentType: NSAttributedString.DocumentType.html,
|
|
||||||
.characterEncoding: String.Encoding.utf8.rawValue
|
|
||||||
]
|
|
||||||
if let attributedString = try? NSAttributedString(data: data, options: options, documentAttributes: nil) {
|
|
||||||
return attributedString.string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return htmlEncodedString
|
|
||||||
}
|
|
||||||
|
|
||||||
private func extractCaptions(from content: JSON) -> [Captions] {
|
private func extractCaptions(from content: JSON) -> [Captions] {
|
||||||
content["captions"].arrayValue.compactMap { details in
|
content["captions"].arrayValue.compactMap { details in
|
||||||
guard let url = URL(string: details["url"].stringValue, relativeTo: account.url) else { return nil }
|
guard let url = URL(string: details["url"].stringValue, relativeTo: account.url) else { return nil }
|
||||||
@ -815,11 +724,9 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
|||||||
|
|
||||||
if type == "channel" {
|
if type == "channel" {
|
||||||
return ContentItem(channel: extractChannel(from: json))
|
return ContentItem(channel: extractChannel(from: json))
|
||||||
}
|
} else if type == "playlist" {
|
||||||
if type == "playlist" {
|
|
||||||
return ContentItem(playlist: extractChannelPlaylist(from: json))
|
return ContentItem(playlist: extractChannelPlaylist(from: json))
|
||||||
}
|
} else if type == "video" {
|
||||||
if type == "video" {
|
|
||||||
return ContentItem(video: extractVideo(from: json))
|
return ContentItem(video: extractVideo(from: json))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -392,7 +392,7 @@ final class PeerTubeAPI: Service, ObservableObject, VideosAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func search(_ query: SearchQuery, page _: String?) -> Resource {
|
func search(_ query: SearchQuery, page _: String?) -> Resource {
|
||||||
resource(baseURL: account.url, path: basePathAppending("search/videos"))
|
var resource = resource(baseURL: account.url, path: basePathAppending("search/videos"))
|
||||||
.withParam("search", query.query)
|
.withParam("search", query.query)
|
||||||
// .withParam("sort_by", query.sortBy.parameter)
|
// .withParam("sort_by", query.sortBy.parameter)
|
||||||
// .withParam("type", "all")
|
// .withParam("type", "all")
|
||||||
@ -409,7 +409,7 @@ final class PeerTubeAPI: Service, ObservableObject, VideosAPI {
|
|||||||
// resource = resource.withParam("page", page)
|
// resource = resource.withParam("page", page)
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// return resource
|
return resource
|
||||||
}
|
}
|
||||||
|
|
||||||
func searchSuggestions(query: String) -> Resource {
|
func searchSuggestions(query: String) -> Resource {
|
||||||
@ -515,8 +515,7 @@ final class PeerTubeAPI: Service, ObservableObject, VideosAPI {
|
|||||||
.dictionaryValue["files"]?.arrayValue.first?
|
.dictionaryValue["files"]?.arrayValue.first?
|
||||||
.dictionaryValue["fileUrl"]?.url
|
.dictionaryValue["fileUrl"]?.url
|
||||||
{
|
{
|
||||||
let resolution = Stream.Resolution.predefined(.hd720p30)
|
streams.append(SingleAssetStream(instance: account.instance, avAsset: AVURLAsset(url: fileURL), resolution: .hd720p30, kind: .stream))
|
||||||
streams.append(SingleAssetStream(instance: account.instance, avAsset: AVURLAsset(url: fileURL), resolution: resolution, kind: .stream))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return streams
|
return streams
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import Alamofire
|
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import Siesta
|
import Siesta
|
||||||
@ -113,11 +112,8 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
content.json.arrayValue.compactMap { self.extractVideo(from: $0) }
|
content.json.arrayValue.compactMap { self.extractVideo(from: $0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
configureTransformer(pathPattern("comments/*")) { (content: Entity<JSON>?) -> CommentsPage in
|
configureTransformer(pathPattern("comments/*")) { (content: Entity<JSON>) -> CommentsPage in
|
||||||
guard let details = content?.json.dictionaryValue else {
|
let details = content.json.dictionaryValue
|
||||||
return CommentsPage(comments: [], nextPage: nil, disabled: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
let comments = details["comments"]?.arrayValue.compactMap { self.extractComment(from: $0) } ?? []
|
let comments = details["comments"]?.arrayValue.compactMap { self.extractComment(from: $0) } ?? []
|
||||||
let nextPage = details["nextpage"]?.string
|
let nextPage = details["nextpage"]?.string
|
||||||
let disabled = details["disabled"]?.bool ?? false
|
let disabled = details["disabled"]?.bool ?? false
|
||||||
@ -152,22 +148,13 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
AF.request(
|
login.request(
|
||||||
login.url,
|
.post,
|
||||||
method: .post,
|
json: ["username": username, "password": password]
|
||||||
parameters: ["username": username, "password": password],
|
|
||||||
encoding: JSONEncoding.default
|
|
||||||
)
|
)
|
||||||
.responseDecodable(of: JSON.self) { [weak self] response in
|
.onSuccess { response in
|
||||||
guard let self else {
|
let token = response.json.dictionaryValue["token"]?.string ?? ""
|
||||||
return
|
if let error = response.json.dictionaryValue["error"]?.string {
|
||||||
}
|
|
||||||
|
|
||||||
switch response.result {
|
|
||||||
case let .success(value):
|
|
||||||
let json = JSON(value)
|
|
||||||
let token = json.dictionaryValue["token"]?.string ?? ""
|
|
||||||
if let error = json.dictionaryValue["error"]?.string {
|
|
||||||
NavigationModel.shared.presentAlert(
|
NavigationModel.shared.presentAlert(
|
||||||
title: "Account Error",
|
title: "Account Error",
|
||||||
message: error
|
message: error
|
||||||
@ -183,13 +170,6 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.configure()
|
self.configure()
|
||||||
|
|
||||||
case let .failure(error):
|
|
||||||
NavigationModel.shared.presentAlert(
|
|
||||||
title: "Account Error",
|
|
||||||
message: error.localizedDescription
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -415,7 +395,6 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
if let channel = extractChannel(from: content) {
|
if let channel = extractChannel(from: content) {
|
||||||
return ContentItem(channel: channel)
|
return ContentItem(channel: channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -492,35 +471,6 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func nonProxiedAsset(asset: AVURLAsset, completion: @escaping (AVURLAsset?) -> Void) {
|
|
||||||
guard var urlComponents = URLComponents(url: asset.url, resolvingAgainstBaseURL: false) else {
|
|
||||||
completion(asset)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let hostItem = urlComponents.queryItems?.first(where: { $0.name == "host" }),
|
|
||||||
let hostValue = hostItem.value
|
|
||||||
else {
|
|
||||||
completion(asset)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
urlComponents.host = hostValue
|
|
||||||
|
|
||||||
guard let newUrl = urlComponents.url else {
|
|
||||||
completion(asset)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
completion(AVURLAsset(url: newUrl))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overload used for hlsURLS
|
|
||||||
static func nonProxiedAsset(url: URL, completion: @escaping (AVURLAsset?) -> Void) {
|
|
||||||
let asset = AVURLAsset(url: url)
|
|
||||||
nonProxiedAsset(asset: asset, completion: completion)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func extractVideo(from content: JSON) -> Video? {
|
private func extractVideo(from content: JSON) -> Video? {
|
||||||
let details = content.dictionaryValue
|
let details = content.dictionaryValue
|
||||||
|
|
||||||
@ -546,17 +496,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
|
|
||||||
let uploaded = details["uploaded"]?.double
|
let uploaded = details["uploaded"]?.double
|
||||||
var published = (uploaded.isNil || uploaded == -1) ? nil : (uploaded! / 1000).formattedAsRelativeTime()
|
var published = (uploaded.isNil || uploaded == -1) ? nil : (uploaded! / 1000).formattedAsRelativeTime()
|
||||||
var publishedAt: Date?
|
if published.isNil {
|
||||||
|
|
||||||
let dateFormatter = ISO8601DateFormatter()
|
|
||||||
dateFormatter.formatOptions = [.withInternetDateTime]
|
|
||||||
|
|
||||||
if published.isNil,
|
|
||||||
let date = details["uploadDate"]?.string,
|
|
||||||
let formattedDate = dateFormatter.date(from: date)
|
|
||||||
{
|
|
||||||
publishedAt = formattedDate
|
|
||||||
} else {
|
|
||||||
published = (details["uploadedDate"] ?? details["uploadDate"])?.string ?? ""
|
published = (details["uploadedDate"] ?? details["uploadDate"])?.string ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -586,7 +526,6 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
thumbnails: thumbnails,
|
thumbnails: thumbnails,
|
||||||
live: live,
|
live: live,
|
||||||
short: details["isShort"]?.bool ?? (length <= Video.shortLength),
|
short: details["isShort"]?.bool ?? (length <= Video.shortLength),
|
||||||
publishedAt: publishedAt,
|
|
||||||
likes: details["likes"]?.int,
|
likes: details["likes"]?.int,
|
||||||
dislikes: details["dislikes"]?.int,
|
dislikes: details["dislikes"]?.int,
|
||||||
streams: extractStreams(from: content),
|
streams: extractStreams(from: content),
|
||||||
@ -609,8 +548,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return URL(
|
return URL(string: thumbnailURL
|
||||||
string: thumbnailURL
|
|
||||||
.absoluteString
|
.absoluteString
|
||||||
.replacingOccurrences(of: "hqdefault", with: quality.filename)
|
.replacingOccurrences(of: "hqdefault", with: quality.filename)
|
||||||
.replacingOccurrences(of: "maxresdefault", with: quality.filename)
|
.replacingOccurrences(of: "maxresdefault", with: quality.filename)
|
||||||
@ -682,10 +620,6 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
.dictionaryValue["audioStreams"]?
|
.dictionaryValue["audioStreams"]?
|
||||||
.arrayValue
|
.arrayValue
|
||||||
.filter { $0.dictionaryValue["format"]?.string == "M4A" }
|
.filter { $0.dictionaryValue["format"]?.string == "M4A" }
|
||||||
.filter { stream in
|
|
||||||
let type = stream.dictionaryValue["audioTrackType"]?.string
|
|
||||||
return type == nil || type == "ORIGINAL"
|
|
||||||
}
|
|
||||||
.sorted {
|
.sorted {
|
||||||
$0.dictionaryValue["bitrate"]?.int ?? 0 >
|
$0.dictionaryValue["bitrate"]?.int ?? 0 >
|
||||||
$1.dictionaryValue["bitrate"]?.int ?? 0
|
$1.dictionaryValue["bitrate"]?.int ?? 0
|
||||||
@ -697,16 +631,16 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
|
|
||||||
let videoStreams = content.dictionaryValue["videoStreams"]?.arrayValue ?? []
|
let videoStreams = content.dictionaryValue["videoStreams"]?.arrayValue ?? []
|
||||||
|
|
||||||
for videoStream in videoStreams {
|
videoStreams.forEach { videoStream in
|
||||||
let videoCodec = videoStream.dictionaryValue["codec"]?.string ?? ""
|
let videoCodec = videoStream.dictionaryValue["codec"]?.string ?? ""
|
||||||
if Self.disallowedVideoCodecs.contains(where: videoCodec.contains) {
|
if Self.disallowedVideoCodecs.contains(where: videoCodec.contains) {
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let audioAssetUrl = audioStream.dictionaryValue["url"]?.url,
|
guard let audioAssetUrl = audioStream.dictionaryValue["url"]?.url,
|
||||||
let videoAssetUrl = videoStream.dictionaryValue["url"]?.url
|
let videoAssetUrl = videoStream.dictionaryValue["url"]?.url
|
||||||
else {
|
else {
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let audioAsset = AVURLAsset(url: audioAssetUrl)
|
let audioAsset = AVURLAsset(url: audioAssetUrl)
|
||||||
@ -718,20 +652,6 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
let fps = qualityComponents.count > 1 ? Int(qualityComponents[1]) : 30
|
let fps = qualityComponents.count > 1 ? Int(qualityComponents[1]) : 30
|
||||||
let resolution = Stream.Resolution.from(resolution: quality, fps: fps)
|
let resolution = Stream.Resolution.from(resolution: quality, fps: fps)
|
||||||
let videoFormat = videoStream.dictionaryValue["format"]?.string
|
let videoFormat = videoStream.dictionaryValue["format"]?.string
|
||||||
let bitrate = videoStream.dictionaryValue["bitrate"]?.int
|
|
||||||
var requestRange: String?
|
|
||||||
|
|
||||||
if let initStart = videoStream.dictionaryValue["initStart"]?.int,
|
|
||||||
let initEnd = videoStream.dictionaryValue["initEnd"]?.int
|
|
||||||
{
|
|
||||||
requestRange = "\(initStart)-\(initEnd)"
|
|
||||||
} else if let indexStart = videoStream.dictionaryValue["indexStart"]?.int,
|
|
||||||
let indexEnd = videoStream.dictionaryValue["indexEnd"]?.int
|
|
||||||
{
|
|
||||||
requestRange = "\(indexStart)-\(indexEnd)"
|
|
||||||
} else {
|
|
||||||
requestRange = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if videoOnly {
|
if videoOnly {
|
||||||
streams.append(
|
streams.append(
|
||||||
@ -741,9 +661,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
videoAsset: videoAsset,
|
videoAsset: videoAsset,
|
||||||
resolution: resolution,
|
resolution: resolution,
|
||||||
kind: .adaptive,
|
kind: .adaptive,
|
||||||
videoFormat: videoFormat,
|
videoFormat: videoFormat
|
||||||
bitrate: bitrate,
|
|
||||||
requestRange: requestRange
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@ -774,23 +692,15 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|||||||
let commentorUrl = details["commentorUrl"]?.string
|
let commentorUrl = details["commentorUrl"]?.string
|
||||||
let channelId = commentorUrl?.components(separatedBy: "/")[2] ?? ""
|
let channelId = commentorUrl?.components(separatedBy: "/")[2] ?? ""
|
||||||
|
|
||||||
let commentText = extractCommentText(from: details["commentText"]?.stringValue)
|
|
||||||
let commentId = details["commentId"]?.string ?? UUID().uuidString
|
|
||||||
|
|
||||||
// Sanity checks: return nil if required data is missing
|
|
||||||
if commentText.isEmpty || commentId.isEmpty || author.isEmpty {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return Comment(
|
return Comment(
|
||||||
id: commentId,
|
id: details["commentId"]?.string ?? UUID().uuidString,
|
||||||
author: author,
|
author: author,
|
||||||
authorAvatarURL: details["thumbnail"]?.string ?? "",
|
authorAvatarURL: details["thumbnail"]?.string ?? "",
|
||||||
time: details["commentedTime"]?.string ?? "",
|
time: details["commentedTime"]?.string ?? "",
|
||||||
pinned: details["pinned"]?.bool ?? false,
|
pinned: details["pinned"]?.bool ?? false,
|
||||||
hearted: details["hearted"]?.bool ?? false,
|
hearted: details["hearted"]?.bool ?? false,
|
||||||
likeCount: details["likeCount"]?.int ?? 0,
|
likeCount: details["likeCount"]?.int ?? 0,
|
||||||
text: commentText,
|
text: extractCommentText(from: details["commentText"]?.stringValue),
|
||||||
repliesPage: details["repliesPage"]?.string,
|
repliesPage: details["repliesPage"]?.string,
|
||||||
channel: Channel(app: .piped, id: channelId, name: author)
|
channel: Channel(app: .piped, id: channelId, name: author)
|
||||||
)
|
)
|
||||||
|
@ -66,7 +66,7 @@ protocol VideosAPI {
|
|||||||
failureHandler: ((RequestError) -> Void)?,
|
failureHandler: ((RequestError) -> Void)?,
|
||||||
completionHandler: @escaping (PlayerQueueItem) -> Void
|
completionHandler: @escaping (PlayerQueueItem) -> Void
|
||||||
)
|
)
|
||||||
func shareURL(_ item: ContentItem, frontendURLString: String?, time: CMTime?) -> URL?
|
func shareURL(_ item: ContentItem, frontendHost: String?, time: CMTime?) -> URL?
|
||||||
|
|
||||||
func comments(_ id: Video.ID, page: String?) -> Resource?
|
func comments(_ id: Video.ID, page: String?) -> Resource?
|
||||||
}
|
}
|
||||||
@ -108,20 +108,15 @@ extension VideosAPI {
|
|||||||
.onFailure { failureHandler?($0) }
|
.onFailure { failureHandler?($0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func shareURL(_ item: ContentItem, frontendURLString: String? = nil, time: CMTime? = nil) -> URL? {
|
func shareURL(_ item: ContentItem, frontendHost: String? = nil, time: CMTime? = nil) -> URL? {
|
||||||
var urlComponents: URLComponents?
|
guard let frontendHost = frontendHost ?? account?.instance?.frontendHost,
|
||||||
if let frontendURLString,
|
var urlComponents = account?.instance?.urlComponents
|
||||||
let frontendURL = URL(string: frontendURLString)
|
else {
|
||||||
{
|
|
||||||
urlComponents = URLComponents(url: frontendURL, resolvingAgainstBaseURL: false)
|
|
||||||
} else if let instanceComponents = account?.instance?.urlComponents {
|
|
||||||
urlComponents = instanceComponents
|
|
||||||
}
|
|
||||||
|
|
||||||
guard var urlComponents else {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
urlComponents.host = frontendHost
|
||||||
|
|
||||||
var queryItems = [URLQueryItem]()
|
var queryItems = [URLQueryItem]()
|
||||||
|
|
||||||
switch item.contentType {
|
switch item.contentType {
|
||||||
@ -149,52 +144,24 @@ extension VideosAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func extractChapters(from description: String) -> [Chapter] {
|
func extractChapters(from description: String) -> [Chapter] {
|
||||||
/*
|
guard let chaptersRegularExpression = try? NSRegularExpression(
|
||||||
The following chapter patterns are covered:
|
pattern: "(?<start>(?:[0-9]+:){1,}(?:[0-9]+))(?:\\s)+(?:- ?)?(?<title>.*)",
|
||||||
|
options: .caseInsensitive
|
||||||
|
) else { return [] }
|
||||||
|
|
||||||
1) "start - end - title" / "start - end: Title" / "start - end title"
|
let chapterLines = chaptersRegularExpression.matches(
|
||||||
2) "start - title" / "start: title" / "start title" / "[start] - title" / "[start]: title" / "[start] title"
|
in: description,
|
||||||
3) "index. title - start" / "index. title start"
|
range: NSRange(description.startIndex..., in: description)
|
||||||
4) "title: (start)"
|
)
|
||||||
5) "(start) title"
|
|
||||||
|
|
||||||
These represent:
|
return chapterLines.compactMap { line in
|
||||||
|
|
||||||
- "start" and "end" are timestamps, defining the start and end of the individual chapter
|
|
||||||
- "title" is the name of the chapter
|
|
||||||
- "index" is the chapter's position in a list
|
|
||||||
|
|
||||||
The order of these patterns is important as it determines the priority. The patterns listed first have a higher priority.
|
|
||||||
In the case of multiple matches, the pattern with the highest priority will be chosen - lower number means higher priority.
|
|
||||||
*/
|
|
||||||
let patterns = [
|
|
||||||
"(?<=\\n|^)\\s*(?:►\\s*)?\\[?(?<start>(?:[0-9]+:){1,2}[0-9]+)\\]?(?:\\s*-\\s*)?(?<end>(?:[0-9]+:){1,2}[0-9]+)?(?:\\s*-\\s*|\\s*[:]\\s*)?(?<title>.*)(?=\\n|$)",
|
|
||||||
"(?<=\\n|^)\\s*(?:►\\s*)?\\[?(?<start>(?:[0-9]+:){1,2}[0-9]+)\\]?\\s*[-:]?\\s*(?<title>.+)(?=\\n|$)",
|
|
||||||
"(?<=\\n|^)(?<index>[0-9]+\\.\\s)(?<title>.+?)(?:\\s*-\\s*)?(?<start>(?:[0-9]+:){1,2}[0-9]+)(?=\\n|$)",
|
|
||||||
"(?<=\\n|^)(?<title>.+?):\\s*\\((?<start>(?:[0-9]+:){1,2}[0-9]+)\\)(?=\\n|$)",
|
|
||||||
"(?<=^|\\n)\\((?<start>(?:[0-9]+:){1,2}[0-9]+)\\)\\s*(?<title>.+?)(?=\\n|$)"
|
|
||||||
]
|
|
||||||
|
|
||||||
let extractChaptersGroup = DispatchGroup()
|
|
||||||
var capturedChapters: [Int: [Chapter]] = [:]
|
|
||||||
let lock = NSLock()
|
|
||||||
|
|
||||||
for (index, pattern) in patterns.enumerated() {
|
|
||||||
extractChaptersGroup.enter()
|
|
||||||
DispatchQueue.global().async {
|
|
||||||
if let chaptersRegularExpression = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) {
|
|
||||||
let chapterLines = chaptersRegularExpression.matches(in: description, range: NSRange(description.startIndex..., in: description))
|
|
||||||
let extractedChapters = chapterLines.compactMap { line -> Chapter? in
|
|
||||||
let titleRange = line.range(withName: "title")
|
let titleRange = line.range(withName: "title")
|
||||||
let startRange = line.range(withName: "start")
|
let startRange = line.range(withName: "start")
|
||||||
|
|
||||||
guard let titleSubstringRange = Range(titleRange, in: description),
|
guard let titleSubstringRange = Range(titleRange, in: description),
|
||||||
let startSubstringRange = Range(startRange, in: description)
|
let startSubstringRange = Range(startRange, in: description) else { return nil }
|
||||||
else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let titleCapture = String(description[titleSubstringRange]).trimmingCharacters(in: .whitespaces)
|
let titleCapture = String(description[titleSubstringRange])
|
||||||
let startCapture = String(description[startSubstringRange])
|
let startCapture = String(description[startSubstringRange])
|
||||||
let startComponents = startCapture.components(separatedBy: ":")
|
let startComponents = startCapture.components(separatedBy: ":")
|
||||||
guard startComponents.count <= 3 else { return nil }
|
guard startComponents.count <= 3 else { return nil }
|
||||||
@ -214,34 +181,15 @@ extension VideosAPI {
|
|||||||
|
|
||||||
guard var startSeconds = seconds else { return nil }
|
guard var startSeconds = seconds else { return nil }
|
||||||
|
|
||||||
startSeconds += (minutes ?? 0) * 60
|
if let minutes {
|
||||||
startSeconds += (hours ?? 0) * 60 * 60
|
startSeconds += 60 * minutes
|
||||||
|
|
||||||
return Chapter(title: titleCapture, start: startSeconds)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !extractedChapters.isEmpty {
|
if let hours {
|
||||||
lock.lock()
|
startSeconds += 60 * 60 * hours
|
||||||
capturedChapters[index] = extractedChapters
|
|
||||||
lock.unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
extractChaptersGroup.leave()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extractChaptersGroup.wait()
|
return .init(title: titleCapture, start: startSeconds)
|
||||||
|
|
||||||
// Now we sort the keys of the capturedChapters dictionary.
|
|
||||||
// These keys correspond to the priority of each pattern.
|
|
||||||
let sortedKeys = Array(capturedChapters.keys).sorted(by: <)
|
|
||||||
|
|
||||||
// Return first non-empty result in the order of patterns
|
|
||||||
for key in sortedKeys {
|
|
||||||
if let chapters = capturedChapters[key], !chapters.isEmpty {
|
|
||||||
return chapters
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -95,7 +95,7 @@ enum VideosApp: String, CaseIterable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var allowsDisablingVidoesProxying: Bool {
|
var allowsDisablingVidoesProxying: Bool {
|
||||||
self == .invidious || self == .piped
|
self == .invidious
|
||||||
}
|
}
|
||||||
|
|
||||||
var supportsOpeningVideosByID: Bool {
|
var supportsOpeningVideosByID: Bool {
|
||||||
|
@ -13,7 +13,6 @@ struct ChannelPlaylistsCacheModel: CacheModel {
|
|||||||
var storage = try? Storage<String, JSON>(
|
var storage = try? Storage<String, JSON>(
|
||||||
diskConfig: Self.diskConfig,
|
diskConfig: Self.diskConfig,
|
||||||
memoryConfig: Self.memoryConfig,
|
memoryConfig: Self.memoryConfig,
|
||||||
fileManager: FileManager.default,
|
|
||||||
transformer: BaseCacheModel.jsonTransformer
|
transformer: BaseCacheModel.jsonTransformer
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -13,7 +13,6 @@ struct ChannelsCacheModel: CacheModel {
|
|||||||
let storage = try? Storage<String, JSON>(
|
let storage = try? Storage<String, JSON>(
|
||||||
diskConfig: Self.diskConfig,
|
diskConfig: Self.diskConfig,
|
||||||
memoryConfig: Self.memoryConfig,
|
memoryConfig: Self.memoryConfig,
|
||||||
fileManager: FileManager.default,
|
|
||||||
transformer: BaseCacheModel.jsonTransformer
|
transformer: BaseCacheModel.jsonTransformer
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -14,20 +14,17 @@ struct FeedCacheModel: CacheModel {
|
|||||||
let storage = try? Storage<String, JSON>(
|
let storage = try? Storage<String, JSON>(
|
||||||
diskConfig: Self.diskConfig,
|
diskConfig: Self.diskConfig,
|
||||||
memoryConfig: Self.memoryConfig,
|
memoryConfig: Self.memoryConfig,
|
||||||
fileManager: FileManager.default,
|
|
||||||
transformer: BaseCacheModel.jsonTransformer
|
transformer: BaseCacheModel.jsonTransformer
|
||||||
)
|
)
|
||||||
|
|
||||||
func storeFeed(account: Account, videos: [Video]) {
|
func storeFeed(account: Account, videos: [Video]) {
|
||||||
DispatchQueue.global(qos: .background).async {
|
|
||||||
let date = iso8601DateFormatter.string(from: Date())
|
let date = iso8601DateFormatter.string(from: Date())
|
||||||
logger.info("caching feed \(account.feedCacheKey) -- \(date)")
|
logger.info("caching feed \(account.feedCacheKey) -- \(date)")
|
||||||
let feedTimeObject: JSON = ["date": date]
|
let feedTimeObject: JSON = ["date": date]
|
||||||
let videosObject: JSON = ["videos": videos.prefix(cacheLimit).map(\.json.object)]
|
let videosObject: JSON = ["videos": videos.prefix(cacheLimit).map { $0.json.object }]
|
||||||
try? storage?.setObject(feedTimeObject, forKey: feedTimeCacheKey(account.feedCacheKey))
|
try? storage?.setObject(feedTimeObject, forKey: feedTimeCacheKey(account.feedCacheKey))
|
||||||
try? storage?.setObject(videosObject, forKey: account.feedCacheKey)
|
try? storage?.setObject(videosObject, forKey: account.feedCacheKey)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func retrieveFeed(account: Account) -> [Video] {
|
func retrieveFeed(account: Account) -> [Video] {
|
||||||
logger.debug("retrieving cache for \(account.feedCacheKey)")
|
logger.debug("retrieving cache for \(account.feedCacheKey)")
|
||||||
|
@ -14,7 +14,6 @@ struct PlaylistsCacheModel: CacheModel {
|
|||||||
let storage = try? Storage<String, JSON>(
|
let storage = try? Storage<String, JSON>(
|
||||||
diskConfig: Self.diskConfig,
|
diskConfig: Self.diskConfig,
|
||||||
memoryConfig: Self.memoryConfig,
|
memoryConfig: Self.memoryConfig,
|
||||||
fileManager: FileManager.default,
|
|
||||||
transformer: BaseCacheModel.jsonTransformer
|
transformer: BaseCacheModel.jsonTransformer
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -22,7 +21,7 @@ struct PlaylistsCacheModel: CacheModel {
|
|||||||
let date = iso8601DateFormatter.string(from: Date())
|
let date = iso8601DateFormatter.string(from: Date())
|
||||||
logger.info("caching \(playlistCacheKey(account)) -- \(date)")
|
logger.info("caching \(playlistCacheKey(account)) -- \(date)")
|
||||||
let feedTimeObject: JSON = ["date": date]
|
let feedTimeObject: JSON = ["date": date]
|
||||||
let playlistsObject: JSON = ["playlists": playlists.map(\.json.object)]
|
let playlistsObject: JSON = ["playlists": playlists.map { $0.json.object }]
|
||||||
try? storage?.setObject(feedTimeObject, forKey: playlistTimeCacheKey(account))
|
try? storage?.setObject(feedTimeObject, forKey: playlistTimeCacheKey(account))
|
||||||
try? storage?.setObject(playlistsObject, forKey: playlistCacheKey(account))
|
try? storage?.setObject(playlistsObject, forKey: playlistCacheKey(account))
|
||||||
}
|
}
|
||||||
|
@ -15,7 +15,6 @@ final class SubscribedChannelsModel: ObservableObject, CacheModel {
|
|||||||
let storage = try? Storage<String, JSON>(
|
let storage = try? Storage<String, JSON>(
|
||||||
diskConfig: SubscribedChannelsModel.diskConfig,
|
diskConfig: SubscribedChannelsModel.diskConfig,
|
||||||
memoryConfig: SubscribedChannelsModel.memoryConfig,
|
memoryConfig: SubscribedChannelsModel.memoryConfig,
|
||||||
fileManager: FileManager.default,
|
|
||||||
transformer: BaseCacheModel.jsonTransformer
|
transformer: BaseCacheModel.jsonTransformer
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -86,6 +85,7 @@ final class SubscribedChannelsModel: ObservableObject, CacheModel {
|
|||||||
self.error = nil
|
self.error = nil
|
||||||
if let channels: [Channel] = resource.typedContent() {
|
if let channels: [Channel] = resource.typedContent() {
|
||||||
self.channels = channels
|
self.channels = channels
|
||||||
|
channels.forEach { ChannelsCacheModel.shared.storeIfMissing($0) }
|
||||||
self.storeChannels(account: account, channels: channels)
|
self.storeChannels(account: account, channels: channels)
|
||||||
FeedModel.shared.calculateUnwatchedFeed()
|
FeedModel.shared.calculateUnwatchedFeed()
|
||||||
onSuccess()
|
onSuccess()
|
||||||
@ -105,18 +105,16 @@ final class SubscribedChannelsModel: ObservableObject, CacheModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func storeChannels(account: Account, channels: [Channel]) {
|
func storeChannels(account: Account, channels: [Channel]) {
|
||||||
DispatchQueue.global(qos: .background).async {
|
let date = iso8601DateFormatter.string(from: Date())
|
||||||
let date = self.iso8601DateFormatter.string(from: Date())
|
logger.info("caching channels \(channelsDateCacheKey(account)) -- \(date)")
|
||||||
self.logger.info("caching channels \(self.channelsDateCacheKey(account)) -- \(date)")
|
|
||||||
|
|
||||||
channels.forEach { ChannelsCacheModel.shared.storeIfMissing($0) }
|
channels.forEach { ChannelsCacheModel.shared.storeIfMissing($0) }
|
||||||
|
|
||||||
let dateObject: JSON = ["date": date]
|
let dateObject: JSON = ["date": date]
|
||||||
let channelsObject: JSON = ["channels": channels.map(\.json).map(\.object)]
|
let channelsObject: JSON = ["channels": channels.map(\.json).map(\.object)]
|
||||||
|
|
||||||
try? self.storage?.setObject(dateObject, forKey: self.channelsDateCacheKey(account))
|
try? storage?.setObject(dateObject, forKey: channelsDateCacheKey(account))
|
||||||
try? self.storage?.setObject(channelsObject, forKey: self.channelsCacheKey(account))
|
try? storage?.setObject(channelsObject, forKey: channelsCacheKey(account))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getChannels(account: Account) -> [Channel] {
|
func getChannels(account: Account) -> [Channel] {
|
||||||
|
@ -13,7 +13,6 @@ struct VideosCacheModel: CacheModel {
|
|||||||
let storage = try? Storage<String, JSON>(
|
let storage = try? Storage<String, JSON>(
|
||||||
diskConfig: Self.diskConfig,
|
diskConfig: Self.diskConfig,
|
||||||
memoryConfig: Self.memoryConfig,
|
memoryConfig: Self.memoryConfig,
|
||||||
fileManager: FileManager.default,
|
|
||||||
transformer: BaseCacheModel.jsonTransformer
|
transformer: BaseCacheModel.jsonTransformer
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -10,8 +10,6 @@ struct Channel: Identifiable, Hashable {
|
|||||||
case livestreams
|
case livestreams
|
||||||
case shorts
|
case shorts
|
||||||
case channels
|
case channels
|
||||||
case releases
|
|
||||||
case podcasts
|
|
||||||
|
|
||||||
static func from(_ name: String) -> Self? {
|
static func from(_ name: String) -> Self? {
|
||||||
let rawValueMatch = allCases.first { $0.rawValue == name }
|
let rawValueMatch = allCases.first { $0.rawValue == name }
|
||||||
@ -47,10 +45,6 @@ struct Channel: Identifiable, Hashable {
|
|||||||
return "1.square"
|
return "1.square"
|
||||||
case .channels:
|
case .channels:
|
||||||
return "person.3"
|
return "person.3"
|
||||||
case .releases:
|
|
||||||
return "square.stack"
|
|
||||||
case .podcasts:
|
|
||||||
return "radio"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,7 +110,7 @@ struct Channel: Identifiable, Hashable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func hasData(for contentType: ContentType) -> Bool {
|
func hasData(for contentType: ContentType) -> Bool {
|
||||||
tabs.contains { $0.contentType == contentType }
|
return tabs.contains { $0.contentType == contentType }
|
||||||
}
|
}
|
||||||
|
|
||||||
var cacheKey: String {
|
var cacheKey: String {
|
||||||
@ -152,7 +146,7 @@ struct Channel: Identifiable, Hashable {
|
|||||||
"subscriptionsText": subscriptionsText as Any,
|
"subscriptionsText": subscriptionsText as Any,
|
||||||
"totalViews": totalViews as Any,
|
"totalViews": totalViews as Any,
|
||||||
"verified": verified as Any,
|
"verified": verified as Any,
|
||||||
"videos": videos.map(\.json.object)
|
"videos": videos.map { $0.json.object }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -19,7 +19,7 @@ struct ChannelPlaylist: Identifiable {
|
|||||||
"title": title,
|
"title": title,
|
||||||
"thumbnailURL": thumbnailURL?.absoluteString ?? "",
|
"thumbnailURL": thumbnailURL?.absoluteString ?? "",
|
||||||
"channel": channel?.json.object ?? "",
|
"channel": channel?.json.object ?? "",
|
||||||
"videos": videos.map(\.json.object),
|
"videos": videos.map { $0.json.object },
|
||||||
"videosCount": String(videosCount ?? 0)
|
"videosCount": String(videosCount ?? 0)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -35,22 +35,26 @@ final class CommentsModel: ObservableObject {
|
|||||||
|
|
||||||
func load(page: String? = nil) {
|
func load(page: String? = nil) {
|
||||||
guard let video = player.currentVideo else { return }
|
guard let video = player.currentVideo else { return }
|
||||||
guard firstPage || nextPageAvailable else { return }
|
|
||||||
|
if !firstPage && !nextPageAvailable {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
firstPage = page.isNil || page!.isEmpty
|
||||||
|
|
||||||
player
|
player
|
||||||
.playerAPI(video)?
|
.playerAPI(video)?
|
||||||
.comments(video.videoID, page: page)?
|
.comments(video.videoID, page: page)?
|
||||||
.load()
|
.load()
|
||||||
.onSuccess { [weak self] response in
|
.onSuccess { [weak self] response in
|
||||||
guard let self else { return }
|
if let page: CommentsPage = response.typedContent() {
|
||||||
if let commentsPage: CommentsPage = response.typedContent() {
|
self?.all += page.comments
|
||||||
self.all += commentsPage.comments
|
self?.nextPage = page.nextPage
|
||||||
self.nextPage = commentsPage.nextPage
|
self?.disabled = page.disabled
|
||||||
self.disabled = commentsPage.disabled
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onFailure { [weak self] _ in
|
.onFailure { [weak self] requestError in
|
||||||
self?.disabled = true
|
self?.disabled = !requestError.json.dictionaryValue["error"].isNil
|
||||||
}
|
}
|
||||||
.onCompletion { [weak self] _ in
|
.onCompletion { [weak self] _ in
|
||||||
self?.loaded = true
|
self?.loaded = true
|
||||||
|
@ -15,7 +15,7 @@ struct ContentItem: Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func < (lhs: Self, rhs: Self) -> Bool {
|
static func < (lhs: ContentType, rhs: ContentType) -> Bool {
|
||||||
lhs.sortOrder < rhs.sortOrder
|
lhs.sortOrder < rhs.sortOrder
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -30,19 +30,19 @@ struct ContentItem: Identifiable {
|
|||||||
|
|
||||||
var id: String = UUID().uuidString
|
var id: String = UUID().uuidString
|
||||||
|
|
||||||
static func array(of videos: [Video]) -> [Self] {
|
static func array(of videos: [Video]) -> [ContentItem] {
|
||||||
videos.map { Self(video: $0) }
|
videos.map { Self(video: $0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
static func array(of playlists: [ChannelPlaylist]) -> [Self] {
|
static func array(of playlists: [ChannelPlaylist]) -> [ContentItem] {
|
||||||
playlists.map { Self(playlist: $0) }
|
playlists.map { Self(playlist: $0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
static func array(of channels: [Channel]) -> [Self] {
|
static func array(of channels: [Channel]) -> [ContentItem] {
|
||||||
channels.map { Self(channel: $0) }
|
channels.map { Self(channel: $0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
static func < (lhs: Self, rhs: Self) -> Bool {
|
static func < (lhs: ContentItem, rhs: ContentItem) -> Bool {
|
||||||
lhs.contentType < rhs.contentType
|
lhs.contentType < rhs.contentType
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -215,7 +215,7 @@ extension Country {
|
|||||||
case .lk: return "Sri Lanka"
|
case .lk: return "Sri Lanka"
|
||||||
case .se: return "Sweden"
|
case .se: return "Sweden"
|
||||||
case .ch: return "Switzerland"
|
case .ch: return "Switzerland"
|
||||||
case .tw: return "Taiwan"
|
case .tw: return "Taiwan, Province of China[a]"
|
||||||
case .tz: return "Tanzania, United Republic of"
|
case .tz: return "Tanzania, United Republic of"
|
||||||
case .th: return "Thailand"
|
case .th: return "Thailand"
|
||||||
case .tn: return "Tunisia"
|
case .tn: return "Tunisia"
|
||||||
@ -274,7 +274,7 @@ extension Country {
|
|||||||
|
|
||||||
private static func filteredCountries(_ predicate: (String) -> Bool) -> [Country] {
|
private static func filteredCountries(_ predicate: (String) -> Bool) -> [Country] {
|
||||||
Country.allCases
|
Country.allCases
|
||||||
.map(\.name)
|
.map { $0.name }
|
||||||
.filter(predicate)
|
.filter(predicate)
|
||||||
.compactMap { string in Country.allCases.first { $0.name == string } }
|
.compactMap { string in Country.allCases.first { $0.name == string } }
|
||||||
}
|
}
|
||||||
|
@ -47,7 +47,7 @@ struct FavoriteItem: Codable, Equatable, Identifiable, Defaults.Serializable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
static func == (lhs: FavoriteItem, rhs: FavoriteItem) -> Bool {
|
||||||
lhs.section == rhs.section
|
lhs.section == rhs.section
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -17,15 +17,10 @@ struct FavoritesModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func toggle(_ item: FavoriteItem) {
|
func toggle(_ item: FavoriteItem) {
|
||||||
if contains(item) {
|
contains(item) ? remove(item) : add(item)
|
||||||
remove(item)
|
|
||||||
} else {
|
|
||||||
add(item)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func add(_ item: FavoriteItem) {
|
func add(_ item: FavoriteItem) {
|
||||||
if contains(item) { return }
|
|
||||||
all.append(item)
|
all.append(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -123,12 +118,4 @@ struct FavoritesModel {
|
|||||||
func widgetSettings(_ item: FavoriteItem) -> WidgetSettings {
|
func widgetSettings(_ item: FavoriteItem) -> WidgetSettings {
|
||||||
widgetsSettings.first { $0.id == item.widgetSettingsKey } ?? WidgetSettings(id: item.widgetSettingsKey)
|
widgetsSettings.first { $0.id == item.widgetSettingsKey } ?? WidgetSettings(id: item.widgetSettingsKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateWidgetSettings(_ settings: WidgetSettings) {
|
|
||||||
if let index = widgetsSettings.firstIndex(where: { $0.id == settings.id }) {
|
|
||||||
widgetsSettings[index] = settings
|
|
||||||
} else {
|
|
||||||
widgetsSettings.append(settings)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -121,7 +121,7 @@ final class FeedModel: ObservableObject, CacheModel {
|
|||||||
backgroundContext.perform { [weak self] in
|
backgroundContext.perform { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
|
||||||
let watched = self.watchFetchRequestResult(feed, context: self.backgroundContext).filter(\.finished)
|
let watched = self.watchFetchRequestResult(feed, context: self.backgroundContext).filter { $0.finished }
|
||||||
let unwatched = feed.filter { video in !watched.contains { $0.videoID == video.videoID } }
|
let unwatched = feed.filter { video in !watched.contains { $0.videoID == video.videoID } }
|
||||||
let unwatchedCount = max(0, feed.count - watched.count)
|
let unwatchedCount = max(0, feed.count - watched.count)
|
||||||
|
|
||||||
|
@ -47,11 +47,12 @@ extension PlayerModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateWatch(finished: Bool = false, time: CMTime? = nil) {
|
func updateWatch(finished: Bool = false, time: CMTime? = nil) {
|
||||||
guard let currentVideo, saveHistory, isPlaying else { return }
|
guard let currentVideo, saveHistory else { return }
|
||||||
|
|
||||||
let id = currentVideo.videoID
|
let id = currentVideo.videoID
|
||||||
let time = time ?? backend.currentTime
|
let time = time ?? backend.currentTime
|
||||||
let seconds = time?.seconds ?? 0
|
let seconds = time?.seconds ?? 0
|
||||||
|
let duration = playerTime.duration.seconds
|
||||||
if seconds < 3 {
|
if seconds < 3 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -1,23 +0,0 @@
|
|||||||
import Defaults
|
|
||||||
import SwiftyJSON
|
|
||||||
|
|
||||||
final class AdvancedSettingsGroupExporter: SettingsGroupExporter {
|
|
||||||
override var globalJSON: JSON {
|
|
||||||
[
|
|
||||||
"showPlayNowInBackendContextMenu": Defaults[.showPlayNowInBackendContextMenu],
|
|
||||||
"videoLoadingRetryCount": Defaults[.videoLoadingRetryCount],
|
|
||||||
"showMPVPlaybackStats": Defaults[.showMPVPlaybackStats],
|
|
||||||
"mpvEnableLogging": Defaults[.mpvEnableLogging],
|
|
||||||
"mpvCacheSecs": Defaults[.mpvCacheSecs],
|
|
||||||
"mpvCachePauseWait": Defaults[.mpvCachePauseWait],
|
|
||||||
"mpvCachePauseInital": Defaults[.mpvCachePauseInital],
|
|
||||||
"mpvDeinterlace": Defaults[.mpvDeinterlace],
|
|
||||||
"mpvHWdec": Defaults[.mpvHWdec],
|
|
||||||
"mpvDemuxerLavfProbeInfo": Defaults[.mpvDemuxerLavfProbeInfo],
|
|
||||||
"mpvSetRefreshToContentFPS": Defaults[.mpvSetRefreshToContentFPS],
|
|
||||||
"mpvInitialAudioSync": Defaults[.mpvInitialAudioSync],
|
|
||||||
"showCacheStatus": Defaults[.showCacheStatus],
|
|
||||||
"feedCacheSize": Defaults[.feedCacheSize]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,55 +0,0 @@
|
|||||||
import Defaults
|
|
||||||
import SwiftyJSON
|
|
||||||
|
|
||||||
final class BrowsingSettingsGroupExporter: SettingsGroupExporter {
|
|
||||||
override var globalJSON: JSON {
|
|
||||||
[
|
|
||||||
"showHome": Defaults[.showHome],
|
|
||||||
"showOpenActionsInHome": Defaults[.showOpenActionsInHome],
|
|
||||||
"showQueueInHome": Defaults[.showQueueInHome],
|
|
||||||
"showFavoritesInHome": Defaults[.showFavoritesInHome],
|
|
||||||
"favorites": Defaults[.favorites].compactMap { jsonFromString(FavoriteItem.bridge.serialize($0)) },
|
|
||||||
"widgetsSettings": Defaults[.widgetsSettings].compactMap { widgetSettingsJSON($0) },
|
|
||||||
"startupSection": Defaults[.startupSection].rawValue,
|
|
||||||
"showSearchSuggestions": Defaults[.showSearchSuggestions],
|
|
||||||
"visibleSections": Defaults[.visibleSections].compactMap { $0.rawValue },
|
|
||||||
"showOpenActionsToolbarItem": Defaults[.showOpenActionsToolbarItem],
|
|
||||||
"accountPickerDisplaysAnonymousAccounts": Defaults[.accountPickerDisplaysAnonymousAccounts],
|
|
||||||
"showUnwatchedFeedBadges": Defaults[.showUnwatchedFeedBadges],
|
|
||||||
"expandChannelDescription": Defaults[.expandChannelDescription],
|
|
||||||
"keepChannelsWithUnwatchedFeedOnTop": Defaults[.keepChannelsWithUnwatchedFeedOnTop],
|
|
||||||
"showChannelAvatarInChannelsLists": Defaults[.showChannelAvatarInChannelsLists],
|
|
||||||
"showChannelAvatarInVideosListing": Defaults[.showChannelAvatarInVideosListing],
|
|
||||||
"playerButtonSingleTapGesture": Defaults[.playerButtonSingleTapGesture].rawValue,
|
|
||||||
"playerButtonDoubleTapGesture": Defaults[.playerButtonDoubleTapGesture].rawValue,
|
|
||||||
"playerButtonShowsControlButtonsWhenMinimized": Defaults[.playerButtonShowsControlButtonsWhenMinimized],
|
|
||||||
"playerButtonIsExpanded": Defaults[.playerButtonIsExpanded],
|
|
||||||
"playerBarMaxWidth": Defaults[.playerBarMaxWidth],
|
|
||||||
"channelOnThumbnail": Defaults[.channelOnThumbnail],
|
|
||||||
"timeOnThumbnail": Defaults[.timeOnThumbnail],
|
|
||||||
"roundedThumbnails": Defaults[.roundedThumbnails],
|
|
||||||
"thumbnailsQuality": Defaults[.thumbnailsQuality].rawValue
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
override var platformJSON: JSON {
|
|
||||||
var export = JSON()
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
export["showDocuments"].bool = Defaults[.showDocuments]
|
|
||||||
export["lockPortraitWhenBrowsing"].bool = Defaults[.lockPortraitWhenBrowsing]
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if !os(tvOS)
|
|
||||||
export["accountPickerDisplaysUsername"].bool = Defaults[.accountPickerDisplaysUsername]
|
|
||||||
#endif
|
|
||||||
|
|
||||||
return export
|
|
||||||
}
|
|
||||||
|
|
||||||
private func widgetSettingsJSON(_ settings: WidgetSettings) -> JSON {
|
|
||||||
var json = JSON()
|
|
||||||
json.dictionaryObject = WidgetSettingsBridge().serialize(settings)
|
|
||||||
return json
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,42 +0,0 @@
|
|||||||
import Defaults
|
|
||||||
import SwiftyJSON
|
|
||||||
|
|
||||||
final class ConstrolsSettingsGroupExporter: SettingsGroupExporter {
|
|
||||||
override var globalJSON: JSON {
|
|
||||||
[
|
|
||||||
"avPlayerUsesSystemControls": Defaults[.avPlayerUsesSystemControls],
|
|
||||||
"fullscreenPlayerGestureEnabled": Defaults[.fullscreenPlayerGestureEnabled],
|
|
||||||
"horizontalPlayerGestureEnabled": Defaults[.horizontalPlayerGestureEnabled],
|
|
||||||
"seekGestureSensitivity": Defaults[.seekGestureSensitivity],
|
|
||||||
"seekGestureSpeed": Defaults[.seekGestureSpeed],
|
|
||||||
"playerControlsLayout": Defaults[.playerControlsLayout].rawValue,
|
|
||||||
"fullScreenPlayerControlsLayout": Defaults[.fullScreenPlayerControlsLayout].rawValue,
|
|
||||||
"playerControlsBackgroundOpacity": Defaults[.playerControlsBackgroundOpacity],
|
|
||||||
"systemControlsCommands": Defaults[.systemControlsCommands].rawValue,
|
|
||||||
"buttonBackwardSeekDuration": Defaults[.buttonBackwardSeekDuration],
|
|
||||||
"buttonForwardSeekDuration": Defaults[.buttonForwardSeekDuration],
|
|
||||||
"gestureBackwardSeekDuration": Defaults[.gestureBackwardSeekDuration],
|
|
||||||
"gestureForwardSeekDuration": Defaults[.gestureForwardSeekDuration],
|
|
||||||
"systemControlsSeekDuration": Defaults[.systemControlsSeekDuration],
|
|
||||||
"playerControlsSettingsEnabled": Defaults[.playerControlsSettingsEnabled],
|
|
||||||
"playerControlsCloseEnabled": Defaults[.playerControlsCloseEnabled],
|
|
||||||
"playerControlsRestartEnabled": Defaults[.playerControlsRestartEnabled],
|
|
||||||
"playerControlsAdvanceToNextEnabled": Defaults[.playerControlsAdvanceToNextEnabled],
|
|
||||||
"playerControlsPlaybackModeEnabled": Defaults[.playerControlsPlaybackModeEnabled],
|
|
||||||
"playerControlsMusicModeEnabled": Defaults[.playerControlsMusicModeEnabled],
|
|
||||||
"playerActionsButtonLabelStyle": Defaults[.playerActionsButtonLabelStyle].rawValue,
|
|
||||||
"actionButtonShareEnabled": Defaults[.actionButtonShareEnabled],
|
|
||||||
"actionButtonAddToPlaylistEnabled": Defaults[.actionButtonAddToPlaylistEnabled],
|
|
||||||
"actionButtonSubscribeEnabled": Defaults[.actionButtonSubscribeEnabled],
|
|
||||||
"actionButtonSettingsEnabled": Defaults[.actionButtonSettingsEnabled],
|
|
||||||
"actionButtonHideEnabled": Defaults[.actionButtonHideEnabled],
|
|
||||||
"actionButtonCloseEnabled": Defaults[.actionButtonCloseEnabled],
|
|
||||||
"actionButtonFullScreenEnabled": Defaults[.actionButtonFullScreenEnabled],
|
|
||||||
"actionButtonPipEnabled": Defaults[.actionButtonPipEnabled],
|
|
||||||
"actionButtonLockOrientationEnabled": Defaults[.actionButtonLockOrientationEnabled],
|
|
||||||
"actionButtonRestartEnabled": Defaults[.actionButtonRestartEnabled],
|
|
||||||
"actionButtonAdvanceToNextItemEnabled": Defaults[.actionButtonAdvanceToNextItemEnabled],
|
|
||||||
"actionButtonMusicModeEnabled": Defaults[.actionButtonMusicModeEnabled]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,24 +0,0 @@
|
|||||||
import Defaults
|
|
||||||
import SwiftyJSON
|
|
||||||
|
|
||||||
final class HistorySettingsGroupExporter: SettingsGroupExporter {
|
|
||||||
override var globalJSON: JSON {
|
|
||||||
[
|
|
||||||
"saveRecents": Defaults[.saveRecents],
|
|
||||||
"saveHistory": Defaults[.saveHistory],
|
|
||||||
"showRecents": Defaults[.showRecents],
|
|
||||||
"limitRecents": Defaults[.limitRecents],
|
|
||||||
"limitRecentsAmount": Defaults[.limitRecentsAmount],
|
|
||||||
"showWatchingProgress": Defaults[.showWatchingProgress],
|
|
||||||
"saveLastPlayed": Defaults[.saveLastPlayed],
|
|
||||||
|
|
||||||
"watchedVideoPlayNowBehavior": Defaults[.watchedVideoPlayNowBehavior].rawValue,
|
|
||||||
"watchedThreshold": Defaults[.watchedThreshold],
|
|
||||||
"resetWatchedStatusOnPlaying": Defaults[.resetWatchedStatusOnPlaying],
|
|
||||||
|
|
||||||
"watchedVideoStyle": Defaults[.watchedVideoStyle].rawValue,
|
|
||||||
"watchedVideoBadgeColor": Defaults[.watchedVideoBadgeColor].rawValue,
|
|
||||||
"showToggleWatchedStatusButton": Defaults[.showToggleWatchedStatusButton]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,56 +0,0 @@
|
|||||||
import Defaults
|
|
||||||
import SwiftyJSON
|
|
||||||
|
|
||||||
final class LocationsSettingsGroupExporter: SettingsGroupExporter {
|
|
||||||
var includePublicInstances = true
|
|
||||||
var includeInstances = true
|
|
||||||
var includeAccounts = true
|
|
||||||
var includeAccountsUnencryptedPasswords = false
|
|
||||||
|
|
||||||
init(includePublicInstances: Bool = true, includeInstances: Bool = true, includeAccounts: Bool = true, includeAccountsUnencryptedPasswords: Bool = false) {
|
|
||||||
self.includePublicInstances = includePublicInstances
|
|
||||||
self.includeInstances = includeInstances
|
|
||||||
self.includeAccounts = includeAccounts
|
|
||||||
self.includeAccountsUnencryptedPasswords = includeAccountsUnencryptedPasswords
|
|
||||||
}
|
|
||||||
|
|
||||||
override var globalJSON: JSON {
|
|
||||||
var json = JSON()
|
|
||||||
|
|
||||||
if includePublicInstances {
|
|
||||||
json["instancesManifest"].string = Defaults[.instancesManifest]
|
|
||||||
json["countryOfPublicInstances"].string = Defaults[.countryOfPublicInstances] ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if includeInstances {
|
|
||||||
json["instances"].arrayObject = Defaults[.instances].compactMap { instanceJSON($0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
if includeAccounts {
|
|
||||||
json["accounts"].arrayObject = Defaults[.accounts].compactMap { account in
|
|
||||||
var account = account
|
|
||||||
let (username, password) = AccountsModel.getCredentials(account)
|
|
||||||
account.username = username ?? ""
|
|
||||||
if includeAccountsUnencryptedPasswords {
|
|
||||||
account.password = password ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return accountJSON(account).dictionaryObject
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return json
|
|
||||||
}
|
|
||||||
|
|
||||||
private func instanceJSON(_ instance: Instance) -> JSON {
|
|
||||||
var json = JSON()
|
|
||||||
json.dictionaryObject = InstancesBridge().serialize(instance)
|
|
||||||
return json
|
|
||||||
}
|
|
||||||
|
|
||||||
private func accountJSON(_ account: Account) -> JSON {
|
|
||||||
var json = JSON()
|
|
||||||
json.dictionaryObject = AccountsBridge().serialize(account)
|
|
||||||
return json
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,27 +0,0 @@
|
|||||||
import Defaults
|
|
||||||
import SwiftyJSON
|
|
||||||
|
|
||||||
final class OtherDataSettingsGroupExporter: SettingsGroupExporter {
|
|
||||||
override var globalJSON: JSON {
|
|
||||||
[
|
|
||||||
"lastAccountID": Defaults[.lastAccountID] ?? "",
|
|
||||||
"lastInstanceID": Defaults[.lastInstanceID] ?? "",
|
|
||||||
|
|
||||||
"playerRate": Defaults[.playerRate],
|
|
||||||
|
|
||||||
"trendingCategory": Defaults[.trendingCategory].rawValue,
|
|
||||||
"trendingCountry": Defaults[.trendingCountry].rawValue,
|
|
||||||
|
|
||||||
"subscriptionsViewPage": Defaults[.subscriptionsViewPage].rawValue,
|
|
||||||
"subscriptionsListingStyle": Defaults[.subscriptionsListingStyle].rawValue,
|
|
||||||
"popularListingStyle": Defaults[.popularListingStyle].rawValue,
|
|
||||||
"trendingListingStyle": Defaults[.trendingListingStyle].rawValue,
|
|
||||||
"playlistListingStyle": Defaults[.playlistListingStyle].rawValue,
|
|
||||||
"channelPlaylistListingStyle": Defaults[.channelPlaylistListingStyle].rawValue,
|
|
||||||
"searchListingStyle": Defaults[.searchListingStyle].rawValue,
|
|
||||||
|
|
||||||
"hideShorts": Defaults[.hideShorts],
|
|
||||||
"hideWatched": Defaults[.hideWatched]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,54 +0,0 @@
|
|||||||
import Defaults
|
|
||||||
import SwiftyJSON
|
|
||||||
|
|
||||||
final class PlayerSettingsGroupExporter: SettingsGroupExporter {
|
|
||||||
override var globalJSON: JSON {
|
|
||||||
[
|
|
||||||
"playerInstanceID": Defaults[.playerInstanceID] ?? "",
|
|
||||||
"pauseOnHidingPlayer": Defaults[.pauseOnHidingPlayer],
|
|
||||||
"closeVideoOnEOF": Defaults[.closeVideoOnEOF],
|
|
||||||
"exitFullscreenOnEOF": Defaults[.exitFullscreenOnEOF],
|
|
||||||
"expandVideoDescription": Defaults[.expandVideoDescription],
|
|
||||||
"collapsedLinesDescription": Defaults[.collapsedLinesDescription],
|
|
||||||
"showChapters": Defaults[.showChapters],
|
|
||||||
"showChapterThumbnails": Defaults[.showChapterThumbnails],
|
|
||||||
"showChapterThumbnailsOnlyWhenDifferent": Defaults[.showChapterThumbnailsOnlyWhenDifferent],
|
|
||||||
"expandChapters": Defaults[.expandChapters],
|
|
||||||
"showRelated": Defaults[.showRelated],
|
|
||||||
"showInspector": Defaults[.showInspector].rawValue,
|
|
||||||
"playerSidebar": Defaults[.playerSidebar].rawValue,
|
|
||||||
"showKeywords": Defaults[.showKeywords],
|
|
||||||
"enableReturnYouTubeDislike": Defaults[.enableReturnYouTubeDislike],
|
|
||||||
"closePiPOnNavigation": Defaults[.closePiPOnNavigation],
|
|
||||||
"closePiPOnOpeningPlayer": Defaults[.closePiPOnOpeningPlayer],
|
|
||||||
"closePlayerOnOpeningPiP": Defaults[.closePlayerOnOpeningPiP],
|
|
||||||
"captionsAutoShow": Defaults[.captionsAutoShow],
|
|
||||||
"captionsDefaultLanguageCode": Defaults[.captionsDefaultLanguageCode],
|
|
||||||
"captionsFallbackLanguageCode": Defaults[.captionsFallbackLanguageCode],
|
|
||||||
"captionsFontScaleSize": Defaults[.captionsFontScaleSize],
|
|
||||||
"captionsFontColor": Defaults[.captionsFontColor]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
override var platformJSON: JSON {
|
|
||||||
var export = JSON()
|
|
||||||
|
|
||||||
#if !os(macOS)
|
|
||||||
export["pauseOnEnteringBackground"].bool = Defaults[.pauseOnEnteringBackground]
|
|
||||||
#endif
|
|
||||||
|
|
||||||
export["showComments"].bool = Defaults[.showComments]
|
|
||||||
|
|
||||||
#if !os(tvOS)
|
|
||||||
export["showScrollToTopInComments"].bool = Defaults[.showScrollToTopInComments]
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
export["isOrientationLocked"].bool = Defaults[.isOrientationLocked]
|
|
||||||
export["enterFullscreenInLandscape"].bool = Defaults[.enterFullscreenInLandscape]
|
|
||||||
export["rotateToLandscapeOnEnterFullScreen"].string = Defaults[.rotateToLandscapeOnEnterFullScreen].rawValue
|
|
||||||
#endif
|
|
||||||
|
|
||||||
return export
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,21 +0,0 @@
|
|||||||
import Defaults
|
|
||||||
import SwiftyJSON
|
|
||||||
|
|
||||||
final class QualitySettingsGroupExporter: SettingsGroupExporter {
|
|
||||||
override var globalJSON: JSON {
|
|
||||||
[
|
|
||||||
"batteryCellularProfile": Defaults[.batteryCellularProfile],
|
|
||||||
"batteryNonCellularProfile": Defaults[.batteryNonCellularProfile],
|
|
||||||
"chargingCellularProfile": Defaults[.chargingCellularProfile],
|
|
||||||
"chargingNonCellularProfile": Defaults[.chargingNonCellularProfile],
|
|
||||||
"forceAVPlayerForLiveStreams": Defaults[.forceAVPlayerForLiveStreams],
|
|
||||||
"qualityProfiles": Defaults[.qualityProfiles].compactMap { qualityProfileJSON($0) }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
func qualityProfileJSON(_ profile: QualityProfile) -> JSON {
|
|
||||||
var json = JSON()
|
|
||||||
json.dictionaryObject = QualityProfileBridge().serialize(profile)
|
|
||||||
return json
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,16 +0,0 @@
|
|||||||
import Defaults
|
|
||||||
import SwiftyJSON
|
|
||||||
|
|
||||||
final class RecentlyOpenedExporter: SettingsGroupExporter {
|
|
||||||
override var globalJSON: JSON {
|
|
||||||
[
|
|
||||||
"recentlyOpened": Defaults[.recentlyOpened].compactMap { recentItemJSON($0) }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
private func recentItemJSON(_ recentItem: RecentItem) -> JSON {
|
|
||||||
var json = JSON()
|
|
||||||
json.dictionaryObject = RecentItemBridge().serialize(recentItem)
|
|
||||||
return json
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,32 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import SwiftyJSON
|
|
||||||
|
|
||||||
class SettingsGroupExporter { // swiftlint:disable:this final_class
|
|
||||||
var globalJSON: JSON {
|
|
||||||
[]
|
|
||||||
}
|
|
||||||
|
|
||||||
var platformJSON: JSON {
|
|
||||||
[]
|
|
||||||
}
|
|
||||||
|
|
||||||
var exportJSON: JSON {
|
|
||||||
var json = globalJSON
|
|
||||||
|
|
||||||
if !platformJSON.isEmpty {
|
|
||||||
try? json.merge(with: platformJSON)
|
|
||||||
}
|
|
||||||
|
|
||||||
return json
|
|
||||||
}
|
|
||||||
|
|
||||||
func jsonFromString(_ string: String?) -> JSON? {
|
|
||||||
if let data = string?.data(using: .utf8, allowLossyConversion: false),
|
|
||||||
let json = try? JSON(data: data)
|
|
||||||
{
|
|
||||||
return json
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,15 +0,0 @@
|
|||||||
import Defaults
|
|
||||||
import SwiftyJSON
|
|
||||||
|
|
||||||
final class SponsorBlockSettingsGroupExporter: SettingsGroupExporter {
|
|
||||||
override var globalJSON: JSON {
|
|
||||||
[
|
|
||||||
"sponsorBlockInstance": Defaults[.sponsorBlockInstance],
|
|
||||||
"sponsorBlockCategories": Array(Defaults[.sponsorBlockCategories]),
|
|
||||||
"sponsorBlockColors": Defaults[.sponsorBlockColors],
|
|
||||||
"sponsorBlockShowTimeWithSkipsRemoved": Defaults[.sponsorBlockShowTimeWithSkipsRemoved],
|
|
||||||
"sponsorBlockShowCategoriesInTimeline": Defaults[.sponsorBlockShowCategoriesInTimeline],
|
|
||||||
"sponsorBlockShowNoticeAfterSkip": Defaults[.sponsorBlockShowNoticeAfterSkip]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,193 +0,0 @@
|
|||||||
import Defaults
|
|
||||||
import Foundation
|
|
||||||
import SwiftUI
|
|
||||||
import SwiftyJSON
|
|
||||||
|
|
||||||
final class ImportExportSettingsModel: ObservableObject {
|
|
||||||
static let shared = ImportExportSettingsModel()
|
|
||||||
|
|
||||||
static var exportFile: URL {
|
|
||||||
YatteeApp.settingsExportDirectory
|
|
||||||
.appendingPathComponent("Yattee Settings from \(Constants.deviceName).\(settingsExtension)")
|
|
||||||
}
|
|
||||||
|
|
||||||
static var settingsExtension: String {
|
|
||||||
"yatteesettings"
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ExportGroup: String, Identifiable, CaseIterable {
|
|
||||||
case browsingSettings
|
|
||||||
case playerSettings
|
|
||||||
case controlsSettings
|
|
||||||
case qualitySettings
|
|
||||||
case historySettings
|
|
||||||
case sponsorBlockSettings
|
|
||||||
case advancedSettings
|
|
||||||
|
|
||||||
case locationsSettings
|
|
||||||
case instances
|
|
||||||
case accounts
|
|
||||||
case accountsUnencryptedPasswords
|
|
||||||
|
|
||||||
case recentlyOpened
|
|
||||||
case otherData
|
|
||||||
|
|
||||||
static var settingsGroups: [Self] {
|
|
||||||
[.browsingSettings, .playerSettings, .controlsSettings, .qualitySettings, .historySettings, .sponsorBlockSettings, .advancedSettings]
|
|
||||||
}
|
|
||||||
|
|
||||||
static var locationsGroups: [Self] {
|
|
||||||
[.locationsSettings, .instances, .accounts, .accountsUnencryptedPasswords]
|
|
||||||
}
|
|
||||||
|
|
||||||
static var otherGroups: [Self] {
|
|
||||||
[.recentlyOpened, .otherData]
|
|
||||||
}
|
|
||||||
|
|
||||||
var id: RawValue {
|
|
||||||
rawValue
|
|
||||||
}
|
|
||||||
|
|
||||||
var label: String {
|
|
||||||
switch self {
|
|
||||||
case .browsingSettings:
|
|
||||||
return "Browsing"
|
|
||||||
case .playerSettings:
|
|
||||||
return "Player"
|
|
||||||
case .controlsSettings:
|
|
||||||
return "Controls"
|
|
||||||
case .qualitySettings:
|
|
||||||
return "Quality"
|
|
||||||
case .historySettings:
|
|
||||||
return "History"
|
|
||||||
case .sponsorBlockSettings:
|
|
||||||
return "SponsorBlock"
|
|
||||||
case .locationsSettings:
|
|
||||||
return "Public Locations"
|
|
||||||
case .instances:
|
|
||||||
return "Custom Locations"
|
|
||||||
case .accounts:
|
|
||||||
return "Accounts"
|
|
||||||
case .accountsUnencryptedPasswords:
|
|
||||||
return "Accounts passwords (unencrypted)"
|
|
||||||
case .advancedSettings:
|
|
||||||
return "Advanced"
|
|
||||||
case .recentlyOpened:
|
|
||||||
return "Recents"
|
|
||||||
case .otherData:
|
|
||||||
return "Other data"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Published var selectedExportGroups = Set<ExportGroup>()
|
|
||||||
static var defaultExportGroups = Set<ExportGroup>([
|
|
||||||
.browsingSettings,
|
|
||||||
.playerSettings,
|
|
||||||
.controlsSettings,
|
|
||||||
.qualitySettings,
|
|
||||||
.historySettings,
|
|
||||||
.sponsorBlockSettings,
|
|
||||||
.locationsSettings,
|
|
||||||
.instances,
|
|
||||||
.accounts,
|
|
||||||
.advancedSettings
|
|
||||||
])
|
|
||||||
|
|
||||||
@Published var isExportInProgress = false
|
|
||||||
|
|
||||||
private var navigation = NavigationModel.shared
|
|
||||||
private var settings = SettingsModel.shared
|
|
||||||
|
|
||||||
func toggleExportGroupSelection(_ group: ExportGroup) {
|
|
||||||
if isGroupSelected(group) {
|
|
||||||
selectedExportGroups.remove(group)
|
|
||||||
} else {
|
|
||||||
selectedExportGroups.insert(group)
|
|
||||||
}
|
|
||||||
|
|
||||||
removeNotEnabledSelectedGroups()
|
|
||||||
}
|
|
||||||
|
|
||||||
func reset() {
|
|
||||||
isExportInProgress = false
|
|
||||||
selectedExportGroups = Self.defaultExportGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
func reset(_ model: ImportSettingsFileModel? = nil) {
|
|
||||||
reset()
|
|
||||||
|
|
||||||
guard let model else { return }
|
|
||||||
|
|
||||||
selectedExportGroups = selectedExportGroups.filter { model.isGroupIncludedInFile($0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
func exportAction() {
|
|
||||||
DispatchQueue.global(qos: .background).async { [weak self] in
|
|
||||||
var writingOptions: JSONSerialization.WritingOptions = []
|
|
||||||
#if DEBUG
|
|
||||||
writingOptions.insert(.prettyPrinted)
|
|
||||||
writingOptions.insert(.sortedKeys)
|
|
||||||
#endif
|
|
||||||
try? self?.jsonForExport?.rawString(options: writingOptions)?.write(to: Self.exportFile, atomically: true, encoding: String.Encoding.utf8)
|
|
||||||
#if os(macOS)
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
self?.isExportInProgress = false
|
|
||||||
}
|
|
||||||
NSWorkspace.shared.selectFile(Self.exportFile.path, inFileViewerRootedAtPath: YatteeApp.settingsExportDirectory.path)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var jsonForExport: JSON? {
|
|
||||||
[
|
|
||||||
"metadata": metadataJSON,
|
|
||||||
"browsingSettings": selectedExportGroups.contains(.browsingSettings) ? BrowsingSettingsGroupExporter().exportJSON : JSON(),
|
|
||||||
"playerSettings": selectedExportGroups.contains(.playerSettings) ? PlayerSettingsGroupExporter().exportJSON : JSON(),
|
|
||||||
"controlsSettings": selectedExportGroups.contains(.controlsSettings) ? ConstrolsSettingsGroupExporter().exportJSON : JSON(),
|
|
||||||
"qualitySettings": selectedExportGroups.contains(.qualitySettings) ? QualitySettingsGroupExporter().exportJSON : JSON(),
|
|
||||||
"historySettings": selectedExportGroups.contains(.historySettings) ? HistorySettingsGroupExporter().exportJSON : JSON(),
|
|
||||||
"sponsorBlockSettings": selectedExportGroups.contains(.sponsorBlockSettings) ? SponsorBlockSettingsGroupExporter().exportJSON : JSON(),
|
|
||||||
"locationsSettings": LocationsSettingsGroupExporter(
|
|
||||||
includePublicInstances: isGroupSelected(.locationsSettings),
|
|
||||||
includeInstances: isGroupSelected(.instances),
|
|
||||||
includeAccounts: isGroupSelected(.accounts),
|
|
||||||
includeAccountsUnencryptedPasswords: isGroupSelected(.accountsUnencryptedPasswords)
|
|
||||||
).exportJSON,
|
|
||||||
"advancedSettings": selectedExportGroups.contains(.advancedSettings) ? AdvancedSettingsGroupExporter().exportJSON : JSON(),
|
|
||||||
"recentlyOpened": selectedExportGroups.contains(.recentlyOpened) ? RecentlyOpenedExporter().exportJSON : JSON(),
|
|
||||||
"otherData": selectedExportGroups.contains(.otherData) ? OtherDataSettingsGroupExporter().exportJSON : JSON()
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
private var metadataJSON: JSON {
|
|
||||||
[
|
|
||||||
"build": YatteeApp.build,
|
|
||||||
"timestamp": "\(Date().timeIntervalSince1970)",
|
|
||||||
"platform": Constants.platform
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
func isGroupSelected(_ group: ExportGroup) -> Bool {
|
|
||||||
selectedExportGroups.contains(group)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isGroupEnabled(_ group: ExportGroup) -> Bool {
|
|
||||||
switch group {
|
|
||||||
case .accounts:
|
|
||||||
return selectedExportGroups.contains(.instances)
|
|
||||||
case .accountsUnencryptedPasswords:
|
|
||||||
return selectedExportGroups.contains(.instances) && selectedExportGroups.contains(.accounts)
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeNotEnabledSelectedGroups() {
|
|
||||||
selectedExportGroups = selectedExportGroups.filter { isGroupEnabled($0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
var isExportAvailable: Bool {
|
|
||||||
!selectedExportGroups.isEmpty && !isExportInProgress
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,150 +0,0 @@
|
|||||||
import Defaults
|
|
||||||
import Foundation
|
|
||||||
import SwiftyJSON
|
|
||||||
|
|
||||||
final class ImportSettingsFileModel: ObservableObject {
|
|
||||||
static let shared = ImportSettingsFileModel()
|
|
||||||
|
|
||||||
var locationsSettingsGroupImporter: LocationsSettingsGroupImporter? {
|
|
||||||
if let locationsSettings = json.dictionaryValue["locationsSettings"] {
|
|
||||||
return LocationsSettingsGroupImporter(
|
|
||||||
json: locationsSettings,
|
|
||||||
includePublicLocations: importExportModel.isGroupEnabled(.locationsSettings),
|
|
||||||
includedInstancesIDs: sheetViewModel.selectedInstances,
|
|
||||||
includedAccountsIDs: sheetViewModel.selectedAccounts,
|
|
||||||
includedAccountsPasswords: sheetViewModel.importableAccountsPasswords
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var importExportModel = ImportExportSettingsModel.shared
|
|
||||||
var sheetViewModel = ImportSettingsSheetViewModel.shared
|
|
||||||
|
|
||||||
var loadTask: URLSessionTask?
|
|
||||||
|
|
||||||
func isGroupIncludedInFile(_ group: ImportExportSettingsModel.ExportGroup) -> Bool {
|
|
||||||
switch group {
|
|
||||||
case .locationsSettings:
|
|
||||||
return isPublicInstancesSettingsGroupInFile || instancesOrAccountsInFile
|
|
||||||
default:
|
|
||||||
return !groupJSON(group).isEmpty
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var isPublicInstancesSettingsGroupInFile: Bool {
|
|
||||||
guard let dict = groupJSON(.locationsSettings).dictionary else { return false }
|
|
||||||
|
|
||||||
return dict.keys.contains("instancesManifest") || dict.keys.contains("countryOfPublicInstances")
|
|
||||||
}
|
|
||||||
|
|
||||||
var instancesOrAccountsInFile: Bool {
|
|
||||||
guard let dict = groupJSON(.locationsSettings).dictionary else { return false }
|
|
||||||
|
|
||||||
return (dict.keys.contains("instances") && !(dict["instances"]?.arrayValue.isEmpty ?? true)) ||
|
|
||||||
(dict.keys.contains("accounts") && !(dict["accounts"]?.arrayValue.isEmpty ?? true))
|
|
||||||
}
|
|
||||||
|
|
||||||
func groupJSON(_ group: ImportExportSettingsModel.ExportGroup) -> JSON {
|
|
||||||
json.dictionaryValue[group.rawValue] ?? .init()
|
|
||||||
}
|
|
||||||
|
|
||||||
func performImport() {
|
|
||||||
if importExportModel.isGroupSelected(.browsingSettings), isGroupIncludedInFile(.browsingSettings) {
|
|
||||||
BrowsingSettingsGroupImporter(json: groupJSON(.browsingSettings)).performImport()
|
|
||||||
}
|
|
||||||
|
|
||||||
if importExportModel.isGroupSelected(.playerSettings), isGroupIncludedInFile(.playerSettings) {
|
|
||||||
PlayerSettingsGroupImporter(json: groupJSON(.playerSettings)).performImport()
|
|
||||||
}
|
|
||||||
|
|
||||||
if importExportModel.isGroupSelected(.controlsSettings), isGroupIncludedInFile(.controlsSettings) {
|
|
||||||
ConstrolsSettingsGroupImporter(json: groupJSON(.controlsSettings)).performImport()
|
|
||||||
}
|
|
||||||
|
|
||||||
if importExportModel.isGroupSelected(.qualitySettings), isGroupIncludedInFile(.qualitySettings) {
|
|
||||||
QualitySettingsGroupImporter(json: groupJSON(.qualitySettings)).performImport()
|
|
||||||
}
|
|
||||||
|
|
||||||
if importExportModel.isGroupSelected(.historySettings), isGroupIncludedInFile(.historySettings) {
|
|
||||||
HistorySettingsGroupImporter(json: groupJSON(.historySettings)).performImport()
|
|
||||||
}
|
|
||||||
|
|
||||||
if importExportModel.isGroupSelected(.sponsorBlockSettings), isGroupIncludedInFile(.sponsorBlockSettings) {
|
|
||||||
SponsorBlockSettingsGroupImporter(json: groupJSON(.sponsorBlockSettings)).performImport()
|
|
||||||
}
|
|
||||||
|
|
||||||
locationsSettingsGroupImporter?.performImport()
|
|
||||||
|
|
||||||
if importExportModel.isGroupSelected(.advancedSettings), isGroupIncludedInFile(.advancedSettings) {
|
|
||||||
AdvancedSettingsGroupImporter(json: groupJSON(.advancedSettings)).performImport()
|
|
||||||
}
|
|
||||||
|
|
||||||
if importExportModel.isGroupSelected(.recentlyOpened), isGroupIncludedInFile(.recentlyOpened) {
|
|
||||||
RecentlyOpenedImporter(json: groupJSON(.recentlyOpened)).performImport()
|
|
||||||
}
|
|
||||||
|
|
||||||
if importExportModel.isGroupSelected(.otherData), isGroupIncludedInFile(.otherData) {
|
|
||||||
OtherDataSettingsGroupImporter(json: groupJSON(.otherData)).performImport()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Published var json = JSON()
|
|
||||||
|
|
||||||
func loadData(_ url: URL) {
|
|
||||||
json = JSON()
|
|
||||||
loadTask?.cancel()
|
|
||||||
|
|
||||||
loadTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
|
|
||||||
guard let data else { return }
|
|
||||||
|
|
||||||
if let json = try? JSON(data: data) {
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
self.json = json
|
|
||||||
|
|
||||||
self.sheetViewModel.reset(locationsSettingsGroupImporter)
|
|
||||||
self.importExportModel.reset(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loadTask?.resume()
|
|
||||||
}
|
|
||||||
|
|
||||||
func filename(_ url: URL) -> String {
|
|
||||||
String(url.lastPathComponent.dropLast(ImportExportSettingsModel.settingsExtension.count + 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
var metadataBuild: String? {
|
|
||||||
if let build = json.dictionaryValue["metadata"]?.dictionaryValue["build"]?.string {
|
|
||||||
return build
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var metadataPlatform: String? {
|
|
||||||
if let platform = json.dictionaryValue["metadata"]?.dictionaryValue["platform"]?.string {
|
|
||||||
return platform
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var metadataDate: String? {
|
|
||||||
if let timestamp = json.dictionaryValue["metadata"]?.dictionaryValue["timestamp"]?.doubleValue {
|
|
||||||
let date = Date(timeIntervalSince1970: timestamp)
|
|
||||||
return dateFormatter.string(from: date)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var dateFormatter: DateFormatter {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateStyle = .long
|
|
||||||
formatter.timeStyle = .medium
|
|
||||||
|
|
||||||
return formatter
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,64 +0,0 @@
|
|||||||
import Defaults
|
|
||||||
import SwiftyJSON
|
|
||||||
|
|
||||||
struct AdvancedSettingsGroupImporter {
|
|
||||||
var json: JSON
|
|
||||||
|
|
||||||
func performImport() {
|
|
||||||
if let showPlayNowInBackendContextMenu = json["showPlayNowInBackendContextMenu"].bool {
|
|
||||||
Defaults[.showPlayNowInBackendContextMenu] = showPlayNowInBackendContextMenu
|
|
||||||
}
|
|
||||||
|
|
||||||
if let videoLoadingRetryCount = json["videoLoadingRetryCount"].int {
|
|
||||||
Defaults[.videoLoadingRetryCount] = videoLoadingRetryCount
|
|
||||||
}
|
|
||||||
|
|
||||||
if let showMPVPlaybackStats = json["showMPVPlaybackStats"].bool {
|
|
||||||
Defaults[.showMPVPlaybackStats] = showMPVPlaybackStats
|
|
||||||
}
|
|
||||||
|
|
||||||
if let mpvEnableLogging = json["mpvEnableLogging"].bool {
|
|
||||||
Defaults[.mpvEnableLogging] = mpvEnableLogging
|
|
||||||
}
|
|
||||||
|
|
||||||
if let mpvCacheSecs = json["mpvCacheSecs"].string {
|
|
||||||
Defaults[.mpvCacheSecs] = mpvCacheSecs
|
|
||||||
}
|
|
||||||
|
|
||||||
if let mpvCachePauseWait = json["mpvCachePauseWait"].string {
|
|
||||||
Defaults[.mpvCachePauseWait] = mpvCachePauseWait
|
|
||||||
}
|
|
||||||
|
|
||||||
if let mpvCachePauseInital = json["mpvCachePauseInital"].bool {
|
|
||||||
Defaults[.mpvCachePauseInital] = mpvCachePauseInital
|
|
||||||
}
|
|
||||||
|
|
||||||
if let mpvDeinterlace = json["mpvDeinterlace"].bool {
|
|
||||||
Defaults[.mpvDeinterlace] = mpvDeinterlace
|
|
||||||
}
|
|
||||||
|
|
||||||
if let mpvHWdec = json["mpvHWdec"].string {
|
|
||||||
Defaults[.mpvHWdec] = mpvHWdec
|
|
||||||
}
|
|
||||||
|
|
||||||
if let mpvDemuxerLavfProbeInfo = json["mpvDemuxerLavfProbeInfo"].string {
|
|
||||||
Defaults[.mpvDemuxerLavfProbeInfo] = mpvDemuxerLavfProbeInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
if let mpvSetRefreshToContentFPS = json["mpvSetRefreshToContentFPS"].bool {
|
|
||||||
Defaults[.mpvSetRefreshToContentFPS] = mpvSetRefreshToContentFPS
|
|
||||||
}
|
|
||||||
|
|
||||||
if let mpvInitialAudioSync = json["mpvInitialAudioSync"].bool {
|
|
||||||
Defaults[.mpvInitialAudioSync] = mpvInitialAudioSync
|
|
||||||
}
|
|
||||||
|
|
||||||
if let showCacheStatus = json["showCacheStatus"].bool {
|
|
||||||
Defaults[.showCacheStatus] = showCacheStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
if let feedCacheSize = json["feedCacheSize"].string {
|
|
||||||
Defaults[.feedCacheSize] = feedCacheSize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,148 +0,0 @@
|
|||||||
import Defaults
|
|
||||||
import SwiftyJSON
|
|
||||||
|
|
||||||
struct BrowsingSettingsGroupImporter {
|
|
||||||
var json: JSON
|
|
||||||
|
|
||||||
func performImport() {
|
|
||||||
if let showHome = json["showHome"].bool {
|
|
||||||
Defaults[.showHome] = showHome
|
|
||||||
}
|
|
||||||
|
|
||||||
if let showOpenActionsInHome = json["showOpenActionsInHome"].bool {
|
|
||||||
Defaults[.showOpenActionsInHome] = showOpenActionsInHome
|
|
||||||
}
|
|
||||||
|
|
||||||
if let showQueueInHome = json["showQueueInHome"].bool {
|
|
||||||
Defaults[.showQueueInHome] = showQueueInHome
|
|
||||||
}
|
|
||||||
|
|
||||||
if let showFavoritesInHome = json["showFavoritesInHome"].bool {
|
|
||||||
Defaults[.showFavoritesInHome] = showFavoritesInHome
|
|
||||||
}
|
|
||||||
|
|
||||||
if let favorites = json["favorites"].array {
|
|
||||||
for favoriteJSON in favorites {
|
|
||||||
if let jsonString = favoriteJSON.rawString(options: []),
|
|
||||||
let item = FavoriteItem.bridge.deserialize(jsonString)
|
|
||||||
{
|
|
||||||
FavoritesModel.shared.add(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let widgetsFavorites = json["widgetsSettings"].array {
|
|
||||||
for widgetJSON in widgetsFavorites {
|
|
||||||
let dict = widgetJSON.dictionaryValue.mapValues { json in json.stringValue }
|
|
||||||
if let item = WidgetSettingsBridge().deserialize(dict) {
|
|
||||||
FavoritesModel.shared.updateWidgetSettings(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let startupSectionString = json["startupSection"].string,
|
|
||||||
let startupSection = StartupSection(rawValue: startupSectionString)
|
|
||||||
{
|
|
||||||
Defaults[.startupSection] = startupSection
|
|
||||||
}
|
|
||||||
|
|
||||||
if let showSearchSuggestions = json["showSearchSuggestions"].bool {
|
|
||||||
Defaults[.showSearchSuggestions] = showSearchSuggestions
|
|
||||||
}
|
|
||||||
|
|
||||||
if let visibleSections = json["visibleSections"].array {
|
|
||||||
let sections = visibleSections.compactMap { visibleSectionJSON in
|
|
||||||
if let visibleSectionString = visibleSectionJSON.rawString(options: []),
|
|
||||||
let section = VisibleSection(rawValue: visibleSectionString)
|
|
||||||
{
|
|
||||||
return section
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
Defaults[.visibleSections] = Set(sections)
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
if let showOpenActionsToolbarItem = json["showOpenActionsToolbarItem"].bool {
|
|
||||||
Defaults[.showOpenActionsToolbarItem] = showOpenActionsToolbarItem
|
|
||||||
}
|
|
||||||
|
|
||||||
if let lockPortraitWhenBrowsing = json["lockPortraitWhenBrowsing"].bool {
|
|
||||||
Defaults[.lockPortraitWhenBrowsing] = lockPortraitWhenBrowsing
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if !os(tvOS)
|
|
||||||
if let accountPickerDisplaysUsername = json["accountPickerDisplaysUsername"].bool {
|
|
||||||
Defaults[.accountPickerDisplaysUsername] = accountPickerDisplaysUsername
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
if let accountPickerDisplaysAnonymousAccounts = json["accountPickerDisplaysAnonymousAccounts"].bool {
|
|
||||||
Defaults[.accountPickerDisplaysAnonymousAccounts] = accountPickerDisplaysAnonymousAccounts
|
|
||||||
}
|
|
||||||
|
|
||||||
if let showUnwatchedFeedBadges = json["showUnwatchedFeedBadges"].bool {
|
|
||||||
Defaults[.showUnwatchedFeedBadges] = showUnwatchedFeedBadges
|
|
||||||
}
|
|
||||||
|
|
||||||
if let expandChannelDescription = json["expandChannelDescription"].bool {
|
|
||||||
Defaults[.expandChannelDescription] = expandChannelDescription
|
|
||||||
}
|
|
||||||
|
|
||||||
if let keepChannelsWithUnwatchedFeedOnTop = json["keepChannelsWithUnwatchedFeedOnTop"].bool {
|
|
||||||
Defaults[.keepChannelsWithUnwatchedFeedOnTop] = keepChannelsWithUnwatchedFeedOnTop
|
|
||||||
}
|
|
||||||
|
|
||||||
if let showChannelAvatarInChannelsLists = json["showChannelAvatarInChannelsLists"].bool {
|
|
||||||
Defaults[.showChannelAvatarInChannelsLists] = showChannelAvatarInChannelsLists
|
|
||||||
}
|
|
||||||
|
|
||||||
if let showChannelAvatarInVideosListing = json["showChannelAvatarInVideosListing"].bool {
|
|
||||||
Defaults[.showChannelAvatarInVideosListing] = showChannelAvatarInVideosListing
|
|
||||||
}
|
|
||||||
|
|
||||||
if let playerButtonSingleTapGestureString = json["playerButtonSingleTapGesture"].string,
|
|
||||||
let playerButtonSingleTapGesture = PlayerTapGestureAction(rawValue: playerButtonSingleTapGestureString)
|
|
||||||
{
|
|
||||||
Defaults[.playerButtonSingleTapGesture] = playerButtonSingleTapGesture
|
|
||||||
}
|
|
||||||
|
|
||||||
if let playerButtonDoubleTapGestureString = json["playerButtonDoubleTapGesture"].string,
|
|
||||||
let playerButtonDoubleTapGesture = PlayerTapGestureAction(rawValue: playerButtonDoubleTapGestureString)
|
|
||||||
{
|
|
||||||
Defaults[.playerButtonDoubleTapGesture] = playerButtonDoubleTapGesture
|
|
||||||
}
|
|
||||||
|
|
||||||
if let playerButtonShowsControlButtonsWhenMinimized = json["playerButtonShowsControlButtonsWhenMinimized"].bool {
|
|
||||||
Defaults[.playerButtonShowsControlButtonsWhenMinimized] = playerButtonShowsControlButtonsWhenMinimized
|
|
||||||
}
|
|
||||||
|
|
||||||
if let playerButtonIsExpanded = json["playerButtonIsExpanded"].bool {
|
|
||||||
Defaults[.playerButtonIsExpanded] = playerButtonIsExpanded
|
|
||||||
}
|
|
||||||
|
|
||||||
if let playerBarMaxWidth = json["playerBarMaxWidth"].string {
|
|
||||||
Defaults[.playerBarMaxWidth] = playerBarMaxWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
if let channelOnThumbnail = json["channelOnThumbnail"].bool {
|
|
||||||
Defaults[.channelOnThumbnail] = channelOnThumbnail
|
|
||||||
}
|
|
||||||
|
|
||||||
if let timeOnThumbnail = json["timeOnThumbnail"].bool {
|
|
||||||
Defaults[.timeOnThumbnail] = timeOnThumbnail
|
|
||||||
}
|
|
||||||
|
|
||||||
if let roundedThumbnails = json["roundedThumbnails"].bool {
|
|
||||||
Defaults[.roundedThumbnails] = roundedThumbnails
|
|
||||||
}
|
|
||||||
|
|
||||||
if let thumbnailsQualityString = json["thumbnailsQuality"].string,
|
|
||||||
let thumbnailsQuality = ThumbnailsQuality(rawValue: thumbnailsQualityString)
|
|
||||||
{
|
|
||||||
Defaults[.thumbnailsQuality] = thumbnailsQuality
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,148 +0,0 @@
|
|||||||
import Defaults
|
|
||||||
import SwiftyJSON
|
|
||||||
|
|
||||||
struct ConstrolsSettingsGroupImporter {
|
|
||||||
var json: JSON
|
|
||||||
|
|
||||||
func performImport() {
|
|
||||||
if let avPlayerUsesSystemControls = json["avPlayerUsesSystemControls"].bool {
|
|
||||||
Defaults[.avPlayerUsesSystemControls] = avPlayerUsesSystemControls
|
|
||||||
}
|
|
||||||
|
|
||||||
if let fullscreenPlayerGestureEnabled = json["fullscreenPlayerGestureEnabled"].bool {
|
|
||||||
Defaults[.fullscreenPlayerGestureEnabled] = fullscreenPlayerGestureEnabled
|
|
||||||
}
|
|
||||||
|
|
||||||
if let horizontalPlayerGestureEnabled = json["horizontalPlayerGestureEnabled"].bool {
|
|
||||||
Defaults[.horizontalPlayerGestureEnabled] = horizontalPlayerGestureEnabled
|
|
||||||
}
|
|
||||||
|
|
||||||
if let seekGestureSensitivity = json["seekGestureSensitivity"].double {
|
|
||||||
Defaults[.seekGestureSensitivity] = seekGestureSensitivity
|
|
||||||
}
|
|
||||||
|
|
||||||
if let seekGestureSpeed = json["seekGestureSpeed"].double {
|
|
||||||
Defaults[.seekGestureSpeed] = seekGestureSpeed
|
|
||||||
}
|
|
||||||
|
|
||||||
if let playerControlsLayoutString = json["playerControlsLayout"].string,
|
|
||||||
let playerControlsLayout = PlayerControlsLayout(rawValue: playerControlsLayoutString)
|
|
||||||
{
|
|
||||||
Defaults[.playerControlsLayout] = playerControlsLayout
|
|
||||||
}
|
|
||||||
|
|
||||||
if let fullScreenPlayerControlsLayoutString = json["fullScreenPlayerControlsLayout"].string,
|
|
||||||
let fullScreenPlayerControlsLayout = PlayerControlsLayout(rawValue: fullScreenPlayerControlsLayoutString)
|
|
||||||
{
|
|
||||||
Defaults[.fullScreenPlayerControlsLayout] = fullScreenPlayerControlsLayout
|
|
||||||
}
|
|
||||||
|
|
||||||
if let playerControlsBackgroundOpacity = json["playerControlsBackgroundOpacity"].double {
|
|
||||||
Defaults[.playerControlsBackgroundOpacity] = playerControlsBackgroundOpacity
|
|
||||||
}
|
|
||||||
|
|
||||||
if let systemControlsCommandsString = json["systemControlsCommands"].string,
|
|
||||||
let systemControlsCommands = SystemControlsCommands(rawValue: systemControlsCommandsString)
|
|
||||||
{
|
|
||||||
Defaults[.systemControlsCommands] = systemControlsCommands
|
|
||||||
}
|
|
||||||
|
|
||||||
if let buttonBackwardSeekDuration = json["buttonBackwardSeekDuration"].string {
|
|
||||||
Defaults[.buttonBackwardSeekDuration] = buttonBackwardSeekDuration
|
|
||||||
}
|
|
||||||
|
|
||||||
if let buttonForwardSeekDuration = json["buttonForwardSeekDuration"].string {
|
|
||||||
Defaults[.buttonForwardSeekDuration] = buttonForwardSeekDuration
|
|
||||||
}
|
|
||||||
|
|
||||||
if let gestureBackwardSeekDuration = json["gestureBackwardSeekDuration"].string {
|
|
||||||
Defaults[.gestureBackwardSeekDuration] = gestureBackwardSeekDuration
|
|
||||||
}
|
|
||||||
|
|
||||||
if let gestureForwardSeekDuration = json["gestureForwardSeekDuration"].string {
|
|
||||||
Defaults[.gestureForwardSeekDuration] = gestureForwardSeekDuration
|
|
||||||
}
|
|
||||||
|
|
||||||
if let systemControlsSeekDuration = json["systemControlsSeekDuration"].string {
|
|
||||||
Defaults[.systemControlsSeekDuration] = systemControlsSeekDuration
|
|
||||||
}
|
|
||||||
|
|
||||||
if let playerControlsSettingsEnabled = json["playerControlsSettingsEnabled"].bool {
|
|
||||||
Defaults[.playerControlsSettingsEnabled] = playerControlsSettingsEnabled
|
|
||||||
}
|
|
||||||
|
|
||||||
if let playerControlsCloseEnabled = json["playerControlsCloseEnabled"].bool {
|
|
||||||
Defaults[.playerControlsCloseEnabled] = playerControlsCloseEnabled
|
|
||||||
}
|
|
||||||
|
|
||||||
if let playerControlsRestartEnabled = json["playerControlsRestartEnabled"].bool {
|
|
||||||
Defaults[.playerControlsRestartEnabled] = playerControlsRestartEnabled
|
|
||||||
}
|
|
||||||
|
|
||||||
if let playerControlsAdvanceToNextEnabled = json["playerControlsAdvanceToNextEnabled"].bool {
|
|
||||||
Defaults[.playerControlsAdvanceToNextEnabled] = playerControlsAdvanceToNextEnabled
|
|
||||||
}
|
|
||||||
|
|
||||||
if let playerControlsPlaybackModeEnabled = json["playerControlsPlaybackModeEnabled"].bool {
|
|
||||||
Defaults[.playerControlsPlaybackModeEnabled] = playerControlsPlaybackModeEnabled
|
|
||||||
}
|
|
||||||
|
|
||||||
if let playerControlsMusicModeEnabled = json["playerControlsMusicModeEnabled"].bool {
|
|
||||||
Defaults[.playerControlsMusicModeEnabled] = playerControlsMusicModeEnabled
|
|
||||||
}
|
|
||||||
|
|
||||||
if let playerActionsButtonLabelStyleString = json["playerActionsButtonLabelStyle"].string,
|
|
||||||
let playerActionsButtonLabelStyle = ButtonLabelStyle(rawValue: playerActionsButtonLabelStyleString)
|
|
||||||
{
|
|
||||||
Defaults[.playerActionsButtonLabelStyle] = playerActionsButtonLabelStyle
|
|
||||||
}
|
|
||||||
|
|
||||||
if let actionButtonShareEnabled = json["actionButtonShareEnabled"].bool {
|
|
||||||
Defaults[.actionButtonShareEnabled] = actionButtonShareEnabled
|
|
||||||
}
|
|
||||||
|
|
||||||
if let actionButtonAddToPlaylistEnabled = json["actionButtonAddToPlaylistEnabled"].bool {
|
|
||||||
Defaults[.actionButtonAddToPlaylistEnabled] = actionButtonAddToPlaylistEnabled
|
|
||||||
}
|
|
||||||
|
|
||||||
if let actionButtonSubscribeEnabled = json["actionButtonSubscribeEnabled"].bool {
|
|
||||||
Defaults[.actionButtonSubscribeEnabled] = actionButtonSubscribeEnabled
|
|
||||||
}
|
|
||||||
|
|
||||||
if let actionButtonSettingsEnabled = json["actionButtonSettingsEnabled"].bool {
|
|
||||||
Defaults[.actionButtonSettingsEnabled] = actionButtonSettingsEnabled
|
|
||||||
}
|
|
||||||
|
|
||||||
if let actionButtonHideEnabled = json["actionButtonHideEnabled"].bool {
|
|
||||||
Defaults[.actionButtonHideEnabled] = actionButtonHideEnabled
|
|
||||||
}
|
|
||||||
|
|
||||||
if let actionButtonCloseEnabled = json["actionButtonCloseEnabled"].bool {
|
|
||||||
Defaults[.actionButtonCloseEnabled] = actionButtonCloseEnabled
|
|
||||||
}
|
|
||||||
|
|
||||||
if let actionButtonFullScreenEnabled = json["actionButtonFullScreenEnabled"].bool {
|
|
||||||
Defaults[.actionButtonFullScreenEnabled] = actionButtonFullScreenEnabled
|
|
||||||
}
|
|
||||||
|
|
||||||
if let actionButtonPipEnabled = json["actionButtonPipEnabled"].bool {
|
|
||||||
Defaults[.actionButtonPipEnabled] = actionButtonPipEnabled
|
|
||||||
}
|
|
||||||
|
|
||||||
if let actionButtonLockOrientationEnabled = json["actionButtonLockOrientationEnabled"].bool {
|
|
||||||
Defaults[.actionButtonLockOrientationEnabled] = actionButtonLockOrientationEnabled
|
|
||||||
}
|
|
||||||
|
|
||||||
if let actionButtonRestartEnabled = json["actionButtonRestartEnabled"].bool {
|
|
||||||
Defaults[.actionButtonRestartEnabled] = actionButtonRestartEnabled
|
|
||||||
}
|
|
||||||
|
|
||||||
if let actionButtonAdvanceToNextItemEnabled = json["actionButtonAdvanceToNextItemEnabled"].bool {
|
|
||||||
Defaults[.actionButtonAdvanceToNextItemEnabled] = actionButtonAdvanceToNextItemEnabled
|
|
||||||
}
|
|
||||||
|
|
||||||
if let actionButtonMusicModeEnabled = json["actionButtonMusicModeEnabled"].bool {
|
|
||||||
Defaults[.actionButtonMusicModeEnabled] = actionButtonMusicModeEnabled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,66 +0,0 @@
|
|||||||
import Defaults
|
|
||||||
import SwiftyJSON
|
|
||||||
|
|
||||||
struct HistorySettingsGroupImporter {
|
|
||||||
var json: JSON
|
|
||||||
|
|
||||||
func performImport() {
|
|
||||||
if let saveRecents = json["saveRecents"].bool {
|
|
||||||
Defaults[.saveRecents] = saveRecents
|
|
||||||
}
|
|
||||||
|
|
||||||
if let saveHistory = json["saveHistory"].bool {
|
|
||||||
Defaults[.saveHistory] = saveHistory
|
|
||||||
}
|
|
||||||
|
|
||||||
if let showRecents = json["showRecents"].bool {
|
|
||||||
Defaults[.showRecents] = showRecents
|
|
||||||
}
|
|
||||||
|
|
||||||
if let limitRecents = json["limitRecents"].bool {
|
|
||||||
Defaults[.limitRecents] = limitRecents
|
|
||||||
}
|
|
||||||
|
|
||||||
if let limitRecentsAmount = json["limitRecentsAmount"].int {
|
|
||||||
Defaults[.limitRecentsAmount] = limitRecentsAmount
|
|
||||||
}
|
|
||||||
|
|
||||||
if let showWatchingProgress = json["showWatchingProgress"].bool {
|
|
||||||
Defaults[.showWatchingProgress] = showWatchingProgress
|
|
||||||
}
|
|
||||||
|
|
||||||
if let saveLastPlayed = json["saveLastPlayed"].bool {
|
|
||||||
Defaults[.saveLastPlayed] = saveLastPlayed
|
|
||||||
}
|
|
||||||
|
|
||||||
if let watchedVideoPlayNowBehaviorString = json["watchedVideoPlayNowBehavior"].string,
|
|
||||||
let watchedVideoPlayNowBehavior = WatchedVideoPlayNowBehavior(rawValue: watchedVideoPlayNowBehaviorString)
|
|
||||||
{
|
|
||||||
Defaults[.watchedVideoPlayNowBehavior] = watchedVideoPlayNowBehavior
|
|
||||||
}
|
|
||||||
|
|
||||||
if let watchedThreshold = json["watchedThreshold"].int {
|
|
||||||
Defaults[.watchedThreshold] = watchedThreshold
|
|
||||||
}
|
|
||||||
|
|
||||||
if let resetWatchedStatusOnPlaying = json["resetWatchedStatusOnPlaying"].bool {
|
|
||||||
Defaults[.resetWatchedStatusOnPlaying] = resetWatchedStatusOnPlaying
|
|
||||||
}
|
|
||||||
|
|
||||||
if let watchedVideoStyleString = json["watchedVideoStyle"].string,
|
|
||||||
let watchedVideoStyle = WatchedVideoStyle(rawValue: watchedVideoStyleString)
|
|
||||||
{
|
|
||||||
Defaults[.watchedVideoStyle] = watchedVideoStyle
|
|
||||||
}
|
|
||||||
|
|
||||||
if let watchedVideoBadgeColorString = json["watchedVideoBadgeColor"].string,
|
|
||||||
let watchedVideoBadgeColor = WatchedVideoBadgeColor(rawValue: watchedVideoBadgeColorString)
|
|
||||||
{
|
|
||||||
Defaults[.watchedVideoBadgeColor] = watchedVideoBadgeColor
|
|
||||||
}
|
|
||||||
|
|
||||||
if let showToggleWatchedStatusButton = json["showToggleWatchedStatusButton"].bool {
|
|
||||||
Defaults[.showToggleWatchedStatusButton] = showToggleWatchedStatusButton
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,84 +0,0 @@
|
|||||||
import Defaults
|
|
||||||
import SwiftyJSON
|
|
||||||
|
|
||||||
struct LocationsSettingsGroupImporter {
|
|
||||||
var json: JSON
|
|
||||||
|
|
||||||
var includePublicLocations = true
|
|
||||||
var includedInstancesIDs = Set<Instance.ID>()
|
|
||||||
var includedAccountsIDs = Set<Account.ID>()
|
|
||||||
var includedAccountsPasswords = [Account.ID: String]()
|
|
||||||
|
|
||||||
init(
|
|
||||||
json: JSON,
|
|
||||||
includePublicLocations: Bool = true,
|
|
||||||
includedInstancesIDs: Set<Instance.ID> = [],
|
|
||||||
includedAccountsIDs: Set<Account.ID> = [],
|
|
||||||
includedAccountsPasswords: [Account.ID: String] = [:]
|
|
||||||
) {
|
|
||||||
self.json = json
|
|
||||||
self.includePublicLocations = includePublicLocations
|
|
||||||
self.includedInstancesIDs = includedInstancesIDs
|
|
||||||
self.includedAccountsIDs = includedAccountsIDs
|
|
||||||
self.includedAccountsPasswords = includedAccountsPasswords
|
|
||||||
}
|
|
||||||
|
|
||||||
var instances: [Instance] {
|
|
||||||
if let instances = json["instances"].array {
|
|
||||||
return instances.compactMap { instanceJSON in
|
|
||||||
let dict = instanceJSON.dictionaryValue.mapValues { json in json.stringValue }
|
|
||||||
return InstancesBridge().deserialize(dict)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
var accounts: [Account] {
|
|
||||||
if let accounts = json["accounts"].array {
|
|
||||||
return accounts.compactMap { accountJSON in
|
|
||||||
let dict = accountJSON.dictionaryValue.mapValues { json in json.stringValue }
|
|
||||||
return AccountsBridge().deserialize(dict)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
func performImport() {
|
|
||||||
if includePublicLocations {
|
|
||||||
Defaults[.instancesManifest] = json["instancesManifest"].string ?? ""
|
|
||||||
Defaults[.countryOfPublicInstances] = json["countryOfPublicInstances"].string ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
instances.filter { includedInstancesIDs.contains($0.id) }.forEach { instance in
|
|
||||||
_ = InstancesModel.shared.insert(id: instance.id, app: instance.app, name: instance.name, url: instance.apiURLString)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let accounts = json["accounts"].array {
|
|
||||||
for accountJSON in accounts {
|
|
||||||
let dict = accountJSON.dictionaryValue.mapValues { json in json.stringValue }
|
|
||||||
if let account = AccountsBridge().deserialize(dict),
|
|
||||||
includedAccountsIDs.contains(account.id)
|
|
||||||
{
|
|
||||||
var password = account.password
|
|
||||||
if password?.isEmpty ?? true {
|
|
||||||
password = includedAccountsPasswords[account.id]
|
|
||||||
}
|
|
||||||
if let password,
|
|
||||||
!password.isEmpty,
|
|
||||||
let instanceID = account.instanceID,
|
|
||||||
let instance = InstancesModel.shared.find(instanceID) ?? InstancesModel.shared.findByURLString(account.urlString)
|
|
||||||
{
|
|
||||||
if !instance.accounts.contains(where: { instanceAccount in
|
|
||||||
let (username, _) = instanceAccount.credentials
|
|
||||||
return username == account.username
|
|
||||||
}) {
|
|
||||||
_ = AccountsModel.add(instance: instance, id: account.id, name: account.name, username: account.username, password: password)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,70 +0,0 @@
|
|||||||
import Defaults
|
|
||||||
import SwiftyJSON
|
|
||||||
|
|
||||||
struct OtherDataSettingsGroupImporter {
|
|
||||||
var json: JSON
|
|
||||||
|
|
||||||
func performImport() {
|
|
||||||
if let lastAccountID = json["lastAccountID"].string {
|
|
||||||
Defaults[.lastAccountID] = lastAccountID
|
|
||||||
}
|
|
||||||
|
|
||||||
if let lastInstanceID = json["lastInstanceID"].string {
|
|
||||||
Defaults[.lastInstanceID] = lastInstanceID
|
|
||||||
}
|
|
||||||
|
|
||||||
if let playerRate = json["playerRate"].double {
|
|
||||||
Defaults[.playerRate] = playerRate
|
|
||||||
}
|
|
||||||
|
|
||||||
if let trendingCategoryString = json["trendingCategory"].string,
|
|
||||||
let trendingCategory = TrendingCategory(rawValue: trendingCategoryString)
|
|
||||||
{
|
|
||||||
Defaults[.trendingCategory] = trendingCategory
|
|
||||||
}
|
|
||||||
|
|
||||||
if let trendingCountryString = json["trendingCountry"].string,
|
|
||||||
let trendingCountry = Country(rawValue: trendingCountryString)
|
|
||||||
{
|
|
||||||
Defaults[.trendingCountry] = trendingCountry
|
|
||||||
}
|
|
||||||
|
|
||||||
if let subscriptionsViewPageString = json["subscriptionsViewPage"].string,
|
|
||||||
let subscriptionsViewPage = SubscriptionsView.Page(rawValue: subscriptionsViewPageString)
|
|
||||||
{
|
|
||||||
Defaults[.subscriptionsViewPage] = subscriptionsViewPage
|
|
||||||
}
|
|
||||||
|
|
||||||
if let subscriptionsListingStyle = json["subscriptionsListingStyle"].string {
|
|
||||||
Defaults[.subscriptionsListingStyle] = ListingStyle(rawValue: subscriptionsListingStyle) ?? .list
|
|
||||||
}
|
|
||||||
|
|
||||||
if let popularListingStyle = json["popularListingStyle"].string {
|
|
||||||
Defaults[.popularListingStyle] = ListingStyle(rawValue: popularListingStyle) ?? .list
|
|
||||||
}
|
|
||||||
|
|
||||||
if let trendingListingStyle = json["trendingListingStyle"].string {
|
|
||||||
Defaults[.trendingListingStyle] = ListingStyle(rawValue: trendingListingStyle) ?? .list
|
|
||||||
}
|
|
||||||
|
|
||||||
if let playlistListingStyle = json["playlistListingStyle"].string {
|
|
||||||
Defaults[.playlistListingStyle] = ListingStyle(rawValue: playlistListingStyle) ?? .list
|
|
||||||
}
|
|
||||||
|
|
||||||
if let channelPlaylistListingStyle = json["channelPlaylistListingStyle"].string {
|
|
||||||
Defaults[.channelPlaylistListingStyle] = ListingStyle(rawValue: channelPlaylistListingStyle) ?? .list
|
|
||||||
}
|
|
||||||
|
|
||||||
if let searchListingStyle = json["searchListingStyle"].string {
|
|
||||||
Defaults[.searchListingStyle] = ListingStyle(rawValue: searchListingStyle) ?? .list
|
|
||||||
}
|
|
||||||
|
|
||||||
if let hideShorts = json["hideShorts"].bool {
|
|
||||||
Defaults[.hideShorts] = hideShorts
|
|
||||||
}
|
|
||||||
|
|
||||||
if let hideWatched = json["hideWatched"].bool {
|
|
||||||
Defaults[.hideWatched] = hideWatched
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,135 +0,0 @@
|
|||||||
import Defaults
|
|
||||||
import SwiftyJSON
|
|
||||||
|
|
||||||
struct PlayerSettingsGroupImporter {
|
|
||||||
var json: JSON
|
|
||||||
|
|
||||||
func performImport() {
|
|
||||||
if let playerInstanceID = json["playerInstanceID"].string {
|
|
||||||
Defaults[.playerInstanceID] = playerInstanceID
|
|
||||||
}
|
|
||||||
|
|
||||||
if let pauseOnHidingPlayer = json["pauseOnHidingPlayer"].bool {
|
|
||||||
Defaults[.pauseOnHidingPlayer] = pauseOnHidingPlayer
|
|
||||||
}
|
|
||||||
|
|
||||||
if let closeVideoOnEOF = json["closeVideoOnEOF"].bool {
|
|
||||||
Defaults[.closeVideoOnEOF] = closeVideoOnEOF
|
|
||||||
}
|
|
||||||
|
|
||||||
if let exitFullscreenOnEOF = json["exitFullscreenOnEOF"].bool {
|
|
||||||
Defaults[.exitFullscreenOnEOF] = exitFullscreenOnEOF
|
|
||||||
}
|
|
||||||
|
|
||||||
if let expandVideoDescription = json["expandVideoDescription"].bool {
|
|
||||||
Defaults[.expandVideoDescription] = expandVideoDescription
|
|
||||||
}
|
|
||||||
|
|
||||||
if let collapsedLinesDescription = json["collapsedLinesDescription"].int {
|
|
||||||
Defaults[.collapsedLinesDescription] = collapsedLinesDescription
|
|
||||||
}
|
|
||||||
|
|
||||||
if let showChapters = json["showChapters"].bool {
|
|
||||||
Defaults[.showChapters] = showChapters
|
|
||||||
}
|
|
||||||
|
|
||||||
if let showChapterThumbnails = json["showChapterThumbnails"].bool {
|
|
||||||
Defaults[.showChapterThumbnails] = showChapterThumbnails
|
|
||||||
}
|
|
||||||
|
|
||||||
if let showChapterThumbnailsOnlyWhenDifferent = json["showChapterThumbnailsOnlyWhenDifferent"].bool {
|
|
||||||
Defaults[.showChapterThumbnailsOnlyWhenDifferent] = showChapterThumbnailsOnlyWhenDifferent
|
|
||||||
}
|
|
||||||
|
|
||||||
if let expandChapters = json["expandChapters"].bool {
|
|
||||||
Defaults[.expandChapters] = expandChapters
|
|
||||||
}
|
|
||||||
|
|
||||||
if let showRelated = json["showRelated"].bool {
|
|
||||||
Defaults[.showRelated] = showRelated
|
|
||||||
}
|
|
||||||
|
|
||||||
if let showInspectorString = json["showInspector"].string,
|
|
||||||
let showInspector = ShowInspectorSetting(rawValue: showInspectorString)
|
|
||||||
{
|
|
||||||
Defaults[.showInspector] = showInspector
|
|
||||||
}
|
|
||||||
|
|
||||||
if let playerSidebarString = json["playerSidebar"].string,
|
|
||||||
let playerSidebar = PlayerSidebarSetting(rawValue: playerSidebarString)
|
|
||||||
{
|
|
||||||
Defaults[.playerSidebar] = playerSidebar
|
|
||||||
}
|
|
||||||
|
|
||||||
if let showKeywords = json["showKeywords"].bool {
|
|
||||||
Defaults[.showKeywords] = showKeywords
|
|
||||||
}
|
|
||||||
|
|
||||||
if let enableReturnYouTubeDislike = json["enableReturnYouTubeDislike"].bool {
|
|
||||||
Defaults[.enableReturnYouTubeDislike] = enableReturnYouTubeDislike
|
|
||||||
}
|
|
||||||
|
|
||||||
if let closePiPOnNavigation = json["closePiPOnNavigation"].bool {
|
|
||||||
Defaults[.closePiPOnNavigation] = closePiPOnNavigation
|
|
||||||
}
|
|
||||||
|
|
||||||
if let closePiPOnOpeningPlayer = json["closePiPOnOpeningPlayer"].bool {
|
|
||||||
Defaults[.closePiPOnOpeningPlayer] = closePiPOnOpeningPlayer
|
|
||||||
}
|
|
||||||
|
|
||||||
if let closePlayerOnOpeningPiP = json["closePlayerOnOpeningPiP"].bool {
|
|
||||||
Defaults[.closePlayerOnOpeningPiP] = closePlayerOnOpeningPiP
|
|
||||||
}
|
|
||||||
|
|
||||||
#if !os(macOS)
|
|
||||||
if let pauseOnEnteringBackground = json["pauseOnEnteringBackground"].bool {
|
|
||||||
Defaults[.pauseOnEnteringBackground] = pauseOnEnteringBackground
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
if let showComments = json["showComments"].bool {
|
|
||||||
Defaults[.showComments] = showComments
|
|
||||||
}
|
|
||||||
#if !os(tvOS)
|
|
||||||
if let showScrollToTopInComments = json["showScrollToTopInComments"].bool {
|
|
||||||
Defaults[.showScrollToTopInComments] = showScrollToTopInComments
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
if let isOrientationLocked = json["isOrientationLocked"].bool {
|
|
||||||
Defaults[.isOrientationLocked] = isOrientationLocked
|
|
||||||
}
|
|
||||||
|
|
||||||
if let enterFullscreenInLandscape = json["enterFullscreenInLandscape"].bool {
|
|
||||||
Defaults[.enterFullscreenInLandscape] = enterFullscreenInLandscape
|
|
||||||
}
|
|
||||||
|
|
||||||
if let rotateToLandscapeOnEnterFullScreenString = json["rotateToLandscapeOnEnterFullScreen"].string,
|
|
||||||
let rotateToLandscapeOnEnterFullScreen = FullScreenRotationSetting(rawValue: rotateToLandscapeOnEnterFullScreenString)
|
|
||||||
{
|
|
||||||
Defaults[.rotateToLandscapeOnEnterFullScreen] = rotateToLandscapeOnEnterFullScreen
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
if let captionsAutoShow = json["captionsAutoShow"].bool {
|
|
||||||
Defaults[.captionsAutoShow] = captionsAutoShow
|
|
||||||
}
|
|
||||||
|
|
||||||
if let captionsDefaultLanguageCode = json["captionsDefaultLanguageCode"].string {
|
|
||||||
Defaults[.captionsDefaultLanguageCode] = captionsDefaultLanguageCode
|
|
||||||
}
|
|
||||||
|
|
||||||
if let captionsFallbackLanguageCode = json["captionsFallbackLanguageCode"].string {
|
|
||||||
Defaults[.captionsFallbackLanguageCode] = captionsFallbackLanguageCode
|
|
||||||
}
|
|
||||||
|
|
||||||
if let captionsFontScaleSize = json["captionsFontScaleSize"].string {
|
|
||||||
Defaults[.captionsFontScaleSize] = captionsFontScaleSize
|
|
||||||
}
|
|
||||||
|
|
||||||
if let captionsFontColor = json["captionsFontColor"].string {
|
|
||||||
Defaults[.captionsFontColor] = captionsFontColor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,37 +0,0 @@
|
|||||||
import Defaults
|
|
||||||
import SwiftyJSON
|
|
||||||
|
|
||||||
struct QualitySettingsGroupImporter {
|
|
||||||
var json: JSON
|
|
||||||
|
|
||||||
func performImport() {
|
|
||||||
if let batteryCellularProfileString = json["batteryCellularProfile"].string {
|
|
||||||
Defaults[.batteryCellularProfile] = batteryCellularProfileString
|
|
||||||
}
|
|
||||||
|
|
||||||
if let batteryNonCellularProfileString = json["batteryNonCellularProfile"].string {
|
|
||||||
Defaults[.batteryNonCellularProfile] = batteryNonCellularProfileString
|
|
||||||
}
|
|
||||||
|
|
||||||
if let chargingCellularProfileString = json["chargingCellularProfile"].string {
|
|
||||||
Defaults[.chargingCellularProfile] = chargingCellularProfileString
|
|
||||||
}
|
|
||||||
|
|
||||||
if let chargingNonCellularProfileString = json["chargingNonCellularProfile"].string {
|
|
||||||
Defaults[.chargingNonCellularProfile] = chargingNonCellularProfileString
|
|
||||||
}
|
|
||||||
|
|
||||||
if let forceAVPlayerForLiveStreams = json["forceAVPlayerForLiveStreams"].bool {
|
|
||||||
Defaults[.forceAVPlayerForLiveStreams] = forceAVPlayerForLiveStreams
|
|
||||||
}
|
|
||||||
|
|
||||||
if let qualityProfiles = json["qualityProfiles"].array {
|
|
||||||
for qualityProfileJSON in qualityProfiles {
|
|
||||||
let dict = qualityProfileJSON.dictionaryValue.mapValues { json in json.stringValue }
|
|
||||||
if let item = QualityProfileBridge().deserialize(dict) {
|
|
||||||
QualityProfilesModel.shared.update(item, item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,17 +0,0 @@
|
|||||||
import Defaults
|
|
||||||
import SwiftyJSON
|
|
||||||
|
|
||||||
struct RecentlyOpenedImporter {
|
|
||||||
var json: JSON
|
|
||||||
|
|
||||||
func performImport() {
|
|
||||||
if let recentlyOpened = json["recentlyOpened"].array {
|
|
||||||
for recentlyOpenedJSON in recentlyOpened {
|
|
||||||
let dict = recentlyOpenedJSON.dictionaryValue.mapValues { json in json.stringValue }
|
|
||||||
if let item = RecentItemBridge().deserialize(dict) {
|
|
||||||
RecentsModel.shared.add(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,33 +0,0 @@
|
|||||||
import Defaults
|
|
||||||
import SwiftyJSON
|
|
||||||
|
|
||||||
struct SponsorBlockSettingsGroupImporter {
|
|
||||||
var json: JSON
|
|
||||||
|
|
||||||
func performImport() {
|
|
||||||
if let sponsorBlockInstance = json["sponsorBlockInstance"].string {
|
|
||||||
Defaults[.sponsorBlockInstance] = sponsorBlockInstance
|
|
||||||
}
|
|
||||||
|
|
||||||
if let sponsorBlockCategories = json["sponsorBlockCategories"].array {
|
|
||||||
Defaults[.sponsorBlockCategories] = Set(sponsorBlockCategories.compactMap { $0.string })
|
|
||||||
}
|
|
||||||
|
|
||||||
if let sponsorBlockColors = json["sponsorBlockColors"].dictionary {
|
|
||||||
let colors = sponsorBlockColors.mapValues { json in json.stringValue }
|
|
||||||
Defaults[.sponsorBlockColors] = colors
|
|
||||||
}
|
|
||||||
|
|
||||||
if let sponsorBlockShowTimeWithSkipsRemoved = json["sponsorBlockShowTimeWithSkipsRemoved"].bool {
|
|
||||||
Defaults[.sponsorBlockShowTimeWithSkipsRemoved] = sponsorBlockShowTimeWithSkipsRemoved
|
|
||||||
}
|
|
||||||
|
|
||||||
if let sponsorBlockShowCategoriesInTimeline = json["sponsorBlockShowCategoriesInTimeline"].bool {
|
|
||||||
Defaults[.sponsorBlockShowCategoriesInTimeline] = sponsorBlockShowCategoriesInTimeline
|
|
||||||
}
|
|
||||||
|
|
||||||
if let sponsorBlockShowNoticeAfterSkip = json["sponsorBlockShowNoticeAfterSkip"].bool {
|
|
||||||
Defaults[.sponsorBlockShowNoticeAfterSkip] = sponsorBlockShowNoticeAfterSkip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -45,8 +45,7 @@ final class InstancesManifest: Service, ObservableObject {
|
|||||||
|
|
||||||
instancesList?.load().onSuccess { response in
|
instancesList?.load().onSuccess { response in
|
||||||
if let instances: [ManifestedInstance] = response.typedContent() {
|
if let instances: [ManifestedInstance] = response.typedContent() {
|
||||||
let countryInstances = instances.filter { $0.country == country }
|
guard let instance = instances.filter { $0.country == country }.randomElement() else { return }
|
||||||
guard let instance = countryInstances.randomElement() else { return }
|
|
||||||
let account = instance.anonymousAccount
|
let account = instance.anonymousAccount
|
||||||
AccountsModel.shared.publicAccount = account
|
AccountsModel.shared.publicAccount = account
|
||||||
if asCurrent {
|
if asCurrent {
|
||||||
|
@ -3,7 +3,7 @@ import Foundation
|
|||||||
|
|
||||||
final class MenuModel: ObservableObject {
|
final class MenuModel: ObservableObject {
|
||||||
static let shared = MenuModel()
|
static let shared = MenuModel()
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = [AnyCancellable]()
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
registerChildModel(AccountsModel.shared)
|
registerChildModel(AccountsModel.shared)
|
||||||
@ -12,16 +12,10 @@ final class MenuModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func registerChildModel<T: ObservableObject>(_ model: T?) {
|
func registerChildModel<T: ObservableObject>(_ model: T?) {
|
||||||
guard let model else {
|
guard !model.isNil else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
model.objectWillChange
|
cancellables.append(model!.objectWillChange.sink { [weak self] _ in self?.objectWillChange.send() })
|
||||||
.receive(on: DispatchQueue.main) // Ensure the update occurs on the main thread
|
|
||||||
.debounce(for: .milliseconds(10), scheduler: DispatchQueue.main) // Debounce to avoid immediate feedback loops
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
self?.objectWillChange.send()
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -65,11 +65,6 @@ final class NavigationModel: ObservableObject {
|
|||||||
|
|
||||||
@Published var tabSelection: TabSelection! { didSet {
|
@Published var tabSelection: TabSelection! { didSet {
|
||||||
if oldValue == tabSelection { multipleTapHandler() }
|
if oldValue == tabSelection { multipleTapHandler() }
|
||||||
if tabSelection == nil, let item = recents.presentedItem {
|
|
||||||
Delay.by(0.2) { [weak self] in
|
|
||||||
self?.tabSelection = .recentlyOpened(item.tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
|
|
||||||
@Published var presentingAddToPlaylist = false
|
@Published var presentingAddToPlaylist = false
|
||||||
@ -107,10 +102,6 @@ final class NavigationModel: ObservableObject {
|
|||||||
|
|
||||||
@Published var presentingFileImporter = false
|
@Published var presentingFileImporter = false
|
||||||
|
|
||||||
@Published var presentingSettingsImportSheet = false
|
|
||||||
@Published var presentingSettingsFileImporter = false
|
|
||||||
@Published var settingsImportURL: URL?
|
|
||||||
|
|
||||||
func openChannel(_ channel: Channel, navigationStyle: NavigationStyle) {
|
func openChannel(_ channel: Channel, navigationStyle: NavigationStyle) {
|
||||||
guard channel.id != Video.fixtureChannelID else {
|
guard channel.id != Video.fixtureChannelID else {
|
||||||
return
|
return
|
||||||
@ -273,8 +264,6 @@ final class NavigationModel: ObservableObject {
|
|||||||
presentingChannel = false
|
presentingChannel = false
|
||||||
presentingPlaylist = false
|
presentingPlaylist = false
|
||||||
presentingOpenVideos = false
|
presentingOpenVideos = false
|
||||||
presentingFileImporter = false
|
|
||||||
presentingSettingsImportSheet = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func hideKeyboard() {
|
func hideKeyboard() {
|
||||||
@ -285,9 +274,8 @@ final class NavigationModel: ObservableObject {
|
|||||||
|
|
||||||
func presentAlert(title: String, message: String? = nil) {
|
func presentAlert(title: String, message: String? = nil) {
|
||||||
let message = message.isNil ? nil : Text(message!)
|
let message = message.isNil ? nil : Text(message!)
|
||||||
let alert = Alert(title: Text(title), message: message)
|
alert = Alert(title: Text(title), message: message)
|
||||||
|
presentingAlert = true
|
||||||
presentAlert(alert)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func presentRequestErrorAlert(_ error: RequestError) {
|
func presentRequestErrorAlert(_ error: RequestError) {
|
||||||
@ -296,11 +284,6 @@ final class NavigationModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func presentAlert(_ alert: Alert) {
|
func presentAlert(_ alert: Alert) {
|
||||||
guard !presentingSettings else {
|
|
||||||
SettingsModel.shared.presentAlert(alert)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
self.alert = alert
|
self.alert = alert
|
||||||
presentingAlert = true
|
presentingAlert = true
|
||||||
}
|
}
|
||||||
@ -323,16 +306,6 @@ final class NavigationModel: ObservableObject {
|
|||||||
print("not implemented")
|
print("not implemented")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func presentSettingsImportSheet(_ url: URL, forceSettings: Bool = false) {
|
|
||||||
guard !presentingSettings, !forceSettings else {
|
|
||||||
ImportExportSettingsModel.shared.reset()
|
|
||||||
SettingsModel.shared.presentSettingsImportSheet(url)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
settingsImportURL = url
|
|
||||||
presentingSettingsImportSheet = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
typealias TabSelection = NavigationModel.TabSelection
|
typealias TabSelection = NavigationModel.TabSelection
|
||||||
|
@ -26,7 +26,7 @@ final class NetworkStateModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var bufferingStateText: String? {
|
var bufferingStateText: String? {
|
||||||
guard detailsAvailable && player.hasStarted else { return nil }
|
guard detailsAvailable else { return nil }
|
||||||
return String(format: "%.0f%%", bufferingState)
|
return String(format: "%.0f%%", bufferingState)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -63,7 +63,7 @@ struct OpenVideosModel {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
func openURLsFromClipboard(removeQueueItems: Bool = false, playbackMode: Self.PlaybackMode = .playNow) {
|
func openURLsFromClipboard(removeQueueItems: Bool = false, playbackMode: OpenVideosModel.PlaybackMode = .playNow) {
|
||||||
if urlsFromClipboard.isEmpty {
|
if urlsFromClipboard.isEmpty {
|
||||||
NavigationModel.shared.alert = Alert(title: Text("Could not find any links to open in your clipboard".localized()))
|
NavigationModel.shared.alert = Alert(title: Text("Could not find any links to open in your clipboard".localized()))
|
||||||
if NavigationModel.shared.presentingOpenVideos {
|
if NavigationModel.shared.presentingOpenVideos {
|
||||||
@ -76,7 +76,7 @@ struct OpenVideosModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func openURLs(_ urls: [URL], removeQueueItems: Bool = false, playbackMode: Self.PlaybackMode = .playNow) {
|
func openURLs(_ urls: [URL], removeQueueItems: Bool = false, playbackMode: OpenVideosModel.PlaybackMode = .playNow) {
|
||||||
guard !urls.isEmpty else {
|
guard !urls.isEmpty else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -147,7 +147,7 @@ struct OpenVideosModel {
|
|||||||
if prepending {
|
if prepending {
|
||||||
videos.reverse()
|
videos.reverse()
|
||||||
}
|
}
|
||||||
for video in videos {
|
videos.forEach { video in
|
||||||
player.enqueueVideo(video, play: false, prepending: prepending, loadDetails: false)
|
player.enqueueVideo(video, play: false, prepending: prepending, loadDetails: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -40,11 +40,6 @@ final class AVPlayerBackend: PlayerBackend {
|
|||||||
|
|
||||||
var isLoadingVideo = false
|
var isLoadingVideo = false
|
||||||
|
|
||||||
var hasStarted = false
|
|
||||||
var isPaused: Bool {
|
|
||||||
avPlayer.timeControlStatus == .paused
|
|
||||||
}
|
|
||||||
|
|
||||||
var isPlaying: Bool {
|
var isPlaying: Bool {
|
||||||
avPlayer.timeControlStatus == .playing
|
avPlayer.timeControlStatus == .playing
|
||||||
}
|
}
|
||||||
@ -102,13 +97,13 @@ final class AVPlayerBackend: PlayerBackend {
|
|||||||
|
|
||||||
private var frequentTimeObserver: Any?
|
private var frequentTimeObserver: Any?
|
||||||
private var infrequentTimeObserver: Any?
|
private var infrequentTimeObserver: Any?
|
||||||
private var playerTimeControlStatusObserver: NSKeyValueObservation?
|
private var playerTimeControlStatusObserver: Any?
|
||||||
|
|
||||||
private var statusObservation: NSKeyValueObservation?
|
private var statusObservation: NSKeyValueObservation?
|
||||||
|
|
||||||
private var timeObserverThrottle = Throttle(interval: 2)
|
private var timeObserverThrottle = Throttle(interval: 2)
|
||||||
|
|
||||||
var controlsUpdates = false
|
internal var controlsUpdates = false
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
addFrequentTimeObserver()
|
addFrequentTimeObserver()
|
||||||
@ -119,37 +114,27 @@ final class AVPlayerBackend: PlayerBackend {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
controller.player = avPlayer
|
controller.player = avPlayer
|
||||||
#endif
|
#endif
|
||||||
logger.info("AVPlayerBackend initialized.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream? {
|
||||||
// Invalidate any observers to avoid memory leaks
|
let sortedByResolution = streams
|
||||||
statusObservation?.invalidate()
|
.filter { ($0.kind == .adaptive || $0.kind == .stream) && $0.resolution <= maxResolution.value }
|
||||||
playerTimeControlStatusObserver?.invalidate()
|
.sorted { $0.resolution > $1.resolution }
|
||||||
|
|
||||||
// Remove any time observers added to AVPlayer
|
return streams.first { $0.kind == .hls } ??
|
||||||
if let frequentObserver = frequentTimeObserver {
|
sortedByResolution.first { $0.kind == .stream } ??
|
||||||
avPlayer.removeTimeObserver(frequentObserver)
|
sortedByResolution.first
|
||||||
}
|
|
||||||
if let infrequentObserver = infrequentTimeObserver {
|
|
||||||
avPlayer.removeTimeObserver(infrequentObserver)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove notification observers
|
|
||||||
removeItemDidPlayToEndTimeObserver()
|
|
||||||
|
|
||||||
logger.info("AVPlayerBackend deinitialized.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func canPlay(_ stream: Stream) -> Bool {
|
func canPlay(_ stream: Stream) -> Bool {
|
||||||
stream.kind == .hls || stream.kind == .stream
|
stream.kind == .hls || stream.kind == .stream || (stream.kind == .adaptive && stream.format == .mp4)
|
||||||
}
|
}
|
||||||
|
|
||||||
func playStream(
|
func playStream(
|
||||||
_ stream: Stream,
|
_ stream: Stream,
|
||||||
of video: Video,
|
of video: Video,
|
||||||
preservingTime: Bool,
|
preservingTime: Bool,
|
||||||
upgrading: Bool
|
upgrading _: Bool
|
||||||
) {
|
) {
|
||||||
isLoadingVideo = true
|
isLoadingVideo = true
|
||||||
|
|
||||||
@ -160,7 +145,7 @@ final class AVPlayerBackend: PlayerBackend {
|
|||||||
_ = url.startAccessingSecurityScopedResource()
|
_ = url.startAccessingSecurityScopedResource()
|
||||||
}
|
}
|
||||||
|
|
||||||
loadSingleAsset(url, stream: stream, of: video, preservingTime: preservingTime, upgrading: upgrading)
|
loadSingleAsset(url, stream: stream, of: video, preservingTime: preservingTime)
|
||||||
} else {
|
} else {
|
||||||
model.logger.info("playing stream with many assets:")
|
model.logger.info("playing stream with many assets:")
|
||||||
model.logger.info("composition audio asset: \(stream.audioAsset.url)")
|
model.logger.info("composition audio asset: \(stream.audioAsset.url)")
|
||||||
@ -175,22 +160,7 @@ final class AVPlayerBackend: PlayerBackend {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// After the video has ended, hitting play restarts the video from the beginning.
|
|
||||||
if currentTime?.seconds.formattedAsPlaybackTime() == model.playerTime.duration.seconds.formattedAsPlaybackTime() &&
|
|
||||||
currentTime!.seconds > 0 && model.playerTime.duration.seconds > 0
|
|
||||||
{
|
|
||||||
seek(to: 0, seekType: .loopRestart)
|
|
||||||
}
|
|
||||||
#if !os(macOS)
|
|
||||||
model.setAudioSessionActive(true)
|
|
||||||
#endif
|
|
||||||
avPlayer.play()
|
avPlayer.play()
|
||||||
|
|
||||||
// Setting hasStarted to true the first time player started
|
|
||||||
if !hasStarted {
|
|
||||||
hasStarted = true
|
|
||||||
}
|
|
||||||
|
|
||||||
model.objectWillChange.send()
|
model.objectWillChange.send()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -198,27 +168,17 @@ final class AVPlayerBackend: PlayerBackend {
|
|||||||
guard avPlayer.timeControlStatus != .paused else {
|
guard avPlayer.timeControlStatus != .paused else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
#if !os(macOS)
|
|
||||||
model.setAudioSessionActive(false)
|
|
||||||
#endif
|
|
||||||
avPlayer.pause()
|
avPlayer.pause()
|
||||||
model.objectWillChange.send()
|
model.objectWillChange.send()
|
||||||
}
|
}
|
||||||
|
|
||||||
func togglePlay() {
|
func togglePlay() {
|
||||||
if isPlaying {
|
isPlaying ? pause() : play()
|
||||||
pause()
|
|
||||||
} else {
|
|
||||||
play()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
#if !os(macOS)
|
|
||||||
model.setAudioSessionActive(false)
|
|
||||||
#endif
|
|
||||||
avPlayer.replaceCurrentItem(with: nil)
|
avPlayer.replaceCurrentItem(with: nil)
|
||||||
hasStarted = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancelLoads() {
|
func cancelLoads() {
|
||||||
@ -255,20 +215,16 @@ final class AVPlayerBackend: PlayerBackend {
|
|||||||
_ url: URL,
|
_ url: URL,
|
||||||
stream: Stream,
|
stream: Stream,
|
||||||
of video: Video,
|
of video: Video,
|
||||||
preservingTime: Bool = false,
|
preservingTime: Bool = false
|
||||||
upgrading: Bool = false
|
|
||||||
) {
|
) {
|
||||||
asset?.cancelLoading()
|
asset?.cancelLoading()
|
||||||
asset = AVURLAsset(
|
asset = AVURLAsset(url: url)
|
||||||
url: url,
|
|
||||||
options: ["AVURLAssetHTTPHeaderFieldsKey": ["User-Agent": "\(UserAgentManager.shared.userAgent)"]]
|
|
||||||
)
|
|
||||||
asset?.loadValuesAsynchronously(forKeys: Self.assetKeysToLoad) { [weak self] in
|
asset?.loadValuesAsynchronously(forKeys: Self.assetKeysToLoad) { [weak self] in
|
||||||
var error: NSError?
|
var error: NSError?
|
||||||
switch self?.asset?.statusOfValue(forKey: "duration", error: &error) {
|
switch self?.asset?.statusOfValue(forKey: "duration", error: &error) {
|
||||||
case .loaded:
|
case .loaded:
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
self?.insertPlayerItem(stream, for: video, preservingTime: preservingTime, upgrading: upgrading)
|
self?.insertPlayerItem(stream, for: video, preservingTime: preservingTime)
|
||||||
}
|
}
|
||||||
case .failed:
|
case .failed:
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
@ -343,17 +299,11 @@ final class AVPlayerBackend: PlayerBackend {
|
|||||||
private func insertPlayerItem(
|
private func insertPlayerItem(
|
||||||
_ stream: Stream,
|
_ stream: Stream,
|
||||||
for video: Video,
|
for video: Video,
|
||||||
preservingTime: Bool = false,
|
preservingTime: Bool = false
|
||||||
upgrading: Bool = false
|
|
||||||
) {
|
) {
|
||||||
removeItemDidPlayToEndTimeObserver()
|
removeItemDidPlayToEndTimeObserver()
|
||||||
|
|
||||||
model.playerItem = playerItem(stream)
|
model.playerItem = playerItem(stream)
|
||||||
|
|
||||||
if stream.isHLS {
|
|
||||||
model.playerItem?.preferredPeakBitRate = Double(model.qualityProfile?.resolution.value.bitrate ?? 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard model.playerItem != nil else {
|
guard model.playerItem != nil else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -371,7 +321,7 @@ final class AVPlayerBackend: PlayerBackend {
|
|||||||
|
|
||||||
let startPlaying = {
|
let startPlaying = {
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
self.model.setAudioSessionActive(true)
|
try? AVAudioSession.sharedInstance().setActive(true)
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
self.setRate(self.model.currentRate)
|
self.setRate(self.model.currentRate)
|
||||||
@ -433,7 +383,7 @@ final class AVPlayerBackend: PlayerBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if preservingTime {
|
if preservingTime {
|
||||||
if model.preservedTime.isNil || upgrading {
|
if model.preservedTime.isNil {
|
||||||
model.saveTime {
|
model.saveTime {
|
||||||
replaceItemAndSeek()
|
replaceItemAndSeek()
|
||||||
startPlaying()
|
startPlaying()
|
||||||
@ -464,9 +414,10 @@ final class AVPlayerBackend: PlayerBackend {
|
|||||||
private func playerItem(_: Stream) -> AVPlayerItem? {
|
private func playerItem(_: Stream) -> AVPlayerItem? {
|
||||||
if let asset {
|
if let asset {
|
||||||
return AVPlayerItem(asset: asset)
|
return AVPlayerItem(asset: asset)
|
||||||
}
|
} else {
|
||||||
return AVPlayerItem(asset: composition)
|
return AVPlayerItem(asset: composition)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func attachMetadata() {
|
private func attachMetadata() {
|
||||||
guard let video = model.currentVideo else { return }
|
guard let video = model.currentVideo else { return }
|
||||||
@ -569,7 +520,6 @@ final class AVPlayerBackend: PlayerBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case .failed:
|
case .failed:
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.model.playerError = item.error
|
self.model.playerError = item.error
|
||||||
@ -643,8 +593,6 @@ final class AVPlayerBackend: PlayerBackend {
|
|||||||
if self.controlsUpdates {
|
if self.controlsUpdates {
|
||||||
self.updateControls()
|
self.updateControls()
|
||||||
}
|
}
|
||||||
|
|
||||||
self.model.updateTime(self.currentTime!)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -715,10 +663,6 @@ final class AVPlayerBackend: PlayerBackend {
|
|||||||
} else {
|
} else {
|
||||||
ScreenSaverManager.shared.enable()
|
ScreenSaverManager.shared.enable()
|
||||||
}
|
}
|
||||||
#else
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
UIApplication.shared.isIdleTimerDisabled = self.model.presentingPlayer && self.isPlaying
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
self.timeObserverThrottle.execute {
|
self.timeObserverThrottle.execute {
|
||||||
@ -806,7 +750,7 @@ final class AVPlayerBackend: PlayerBackend {
|
|||||||
opened = true
|
opened = true
|
||||||
controller.startPictureInPicture()
|
controller.startPictureInPicture()
|
||||||
} else {
|
} else {
|
||||||
self.logger.info("PiP not possible, waited \(delay) seconds")
|
print("PiP not possible, waited \(delay) seconds")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,6 @@ import AVFAudio
|
|||||||
import CoreMedia
|
import CoreMedia
|
||||||
import Defaults
|
import Defaults
|
||||||
import Foundation
|
import Foundation
|
||||||
import Libmpv
|
|
||||||
import Logging
|
import Logging
|
||||||
import MediaPlayer
|
import MediaPlayer
|
||||||
import Repeat
|
import Repeat
|
||||||
@ -10,8 +9,7 @@ import SwiftUI
|
|||||||
|
|
||||||
final class MPVBackend: PlayerBackend {
|
final class MPVBackend: PlayerBackend {
|
||||||
static var timeUpdateInterval = 0.5
|
static var timeUpdateInterval = 0.5
|
||||||
static var networkStateUpdateInterval = 0.1
|
static var networkStateUpdateInterval = 1.0
|
||||||
static var refreshRateUpdateInterval = 0.5
|
|
||||||
|
|
||||||
private var logger = Logger(label: "mpv-backend")
|
private var logger = Logger(label: "mpv-backend")
|
||||||
|
|
||||||
@ -23,14 +21,13 @@ final class MPVBackend: PlayerBackend {
|
|||||||
|
|
||||||
var stream: Stream?
|
var stream: Stream?
|
||||||
var video: Video?
|
var video: Video?
|
||||||
var captions: Captions? {
|
var captions: Captions? { didSet {
|
||||||
didSet {
|
guard let captions else {
|
||||||
Task {
|
client?.removeSubs()
|
||||||
await handleCaptionsChange()
|
return
|
||||||
}
|
}
|
||||||
}
|
addSubTrack(captions.url)
|
||||||
}
|
}}
|
||||||
|
|
||||||
var currentTime: CMTime?
|
var currentTime: CMTime?
|
||||||
|
|
||||||
var loadedVideo = false
|
var loadedVideo = false
|
||||||
@ -46,8 +43,6 @@ final class MPVBackend: PlayerBackend {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
||||||
var hasStarted = false
|
|
||||||
var isPaused = false
|
|
||||||
var isPlaying = true { didSet {
|
var isPlaying = true { didSet {
|
||||||
networkStateTimer.start()
|
networkStateTimer.start()
|
||||||
|
|
||||||
@ -91,11 +86,10 @@ final class MPVBackend: PlayerBackend {
|
|||||||
|
|
||||||
private var clientTimer: Repeater!
|
private var clientTimer: Repeater!
|
||||||
private var networkStateTimer: Repeater!
|
private var networkStateTimer: Repeater!
|
||||||
private var refreshRateTimer: Repeater!
|
|
||||||
|
|
||||||
private var onFileLoaded: (() -> Void)?
|
private var onFileLoaded: (() -> Void)?
|
||||||
|
|
||||||
var controlsUpdates = false
|
internal var controlsUpdates = false
|
||||||
private var timeObserverThrottle = Throttle(interval: 2)
|
private var timeObserverThrottle = Throttle(interval: 2)
|
||||||
|
|
||||||
var suggestedPlaybackRates: [Double] {
|
var suggestedPlaybackRates: [Double] {
|
||||||
@ -188,29 +182,41 @@ final class MPVBackend: PlayerBackend {
|
|||||||
|
|
||||||
init() {
|
init() {
|
||||||
clientTimer = .init(interval: .seconds(Self.timeUpdateInterval), mode: .infinite) { [weak self] _ in
|
clientTimer = .init(interval: .seconds(Self.timeUpdateInterval), mode: .infinite) { [weak self] _ in
|
||||||
guard let self, self.model.activeBackend == .mpv else {
|
self?.getTimeUpdates()
|
||||||
return
|
|
||||||
}
|
|
||||||
self.getTimeUpdates()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
networkStateTimer = .init(interval: .seconds(Self.networkStateUpdateInterval), mode: .infinite) { [weak self] _ in
|
networkStateTimer = .init(interval: .seconds(Self.networkStateUpdateInterval), mode: .infinite) { [weak self] _ in
|
||||||
guard let self, self.model.activeBackend == .mpv else {
|
self?.updateNetworkState()
|
||||||
return
|
|
||||||
}
|
|
||||||
self.updateNetworkState()
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshRateTimer = .init(interval: .seconds(Self.refreshRateUpdateInterval), mode: .infinite) { [weak self] _ in
|
|
||||||
guard let self, self.model.activeBackend == .mpv else { return }
|
|
||||||
self.checkAndUpdateRefreshRate()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
typealias AreInIncreasingOrder = (Stream, Stream) -> Bool
|
typealias AreInIncreasingOrder = (Stream, Stream) -> Bool
|
||||||
|
|
||||||
|
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream? {
|
||||||
|
streams
|
||||||
|
.filter { $0.kind != .hls && $0.resolution <= maxResolution.value }
|
||||||
|
.max { lhs, rhs in
|
||||||
|
let predicates: [AreInIncreasingOrder] = [
|
||||||
|
{ $0.resolution < $1.resolution },
|
||||||
|
{ $0.format > $1.format }
|
||||||
|
]
|
||||||
|
|
||||||
|
for predicate in predicates {
|
||||||
|
if !predicate(lhs, rhs), !predicate(rhs, lhs) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return predicate(lhs, rhs)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
} ??
|
||||||
|
streams.first { $0.kind == .hls } ??
|
||||||
|
streams.first
|
||||||
|
}
|
||||||
|
|
||||||
func canPlay(_ stream: Stream) -> Bool {
|
func canPlay(_ stream: Stream) -> Bool {
|
||||||
stream.format != .av1
|
stream.resolution != .unknown && stream.format != .av1
|
||||||
}
|
}
|
||||||
|
|
||||||
func playStream(_ stream: Stream, of video: Video, preservingTime: Bool, upgrading: Bool) {
|
func playStream(_ stream: Stream, of video: Video, preservingTime: Bool, upgrading: Bool) {
|
||||||
@ -223,22 +229,9 @@ final class MPVBackend: PlayerBackend {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
var captions: Captions?
|
var captions: Captions?
|
||||||
|
if let captionsLanguageCode = Defaults[.captionsLanguageCode] {
|
||||||
if Defaults[.captionsAutoShow] == true {
|
captions = video.captions.first { $0.code == captionsLanguageCode } ??
|
||||||
let captionsDefaultLanguageCode = Defaults[.captionsDefaultLanguageCode],
|
video.captions.first { $0.code.contains(captionsLanguageCode) }
|
||||||
captionsFallbackLanguageCode = Defaults[.captionsFallbackLanguageCode]
|
|
||||||
|
|
||||||
// Try to get captions with the default language code first
|
|
||||||
captions = video.captions.first { $0.code == captionsDefaultLanguageCode } ??
|
|
||||||
video.captions.first { $0.code.contains(captionsDefaultLanguageCode) }
|
|
||||||
|
|
||||||
// If there are still no captions, try to get captions with the fallback language code
|
|
||||||
if captions.isNil && !captionsFallbackLanguageCode.isEmpty {
|
|
||||||
captions = video.captions.first { $0.code == captionsFallbackLanguageCode } ??
|
|
||||||
video.captions.first { $0.code.contains(captionsFallbackLanguageCode) }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
captions = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let updateCurrentStream = {
|
let updateCurrentStream = {
|
||||||
@ -252,7 +245,7 @@ final class MPVBackend: PlayerBackend {
|
|||||||
|
|
||||||
let startPlaying = {
|
let startPlaying = {
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
self.model.setAudioSessionActive(true)
|
try? AVAudioSession.sharedInstance().setActive(true)
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
@ -262,9 +255,6 @@ final class MPVBackend: PlayerBackend {
|
|||||||
|
|
||||||
self.startClientUpdates()
|
self.startClientUpdates()
|
||||||
|
|
||||||
if Defaults[.captionsAutoShow] { self.client?.setSubToAuto() } else { self.client?.setSubToNo() }
|
|
||||||
PlayerModel.shared.captions = self.captions
|
|
||||||
|
|
||||||
if !preservingTime,
|
if !preservingTime,
|
||||||
!upgrading,
|
!upgrading,
|
||||||
let segment = self.model.sponsorBlock.segments.first,
|
let segment = self.model.sponsorBlock.segments.first,
|
||||||
@ -310,7 +300,7 @@ final class MPVBackend: PlayerBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
client.loadFile(url, bitrate: stream.bitrate, kind: stream.kind, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
|
client.loadFile(url, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
|
||||||
self?.isLoadingVideo = true
|
self?.isLoadingVideo = true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -322,7 +312,7 @@ final class MPVBackend: PlayerBackend {
|
|||||||
let fileToLoad = self.model.musicMode ? stream.audioAsset.url : stream.videoAsset.url
|
let fileToLoad = self.model.musicMode ? stream.audioAsset.url : stream.videoAsset.url
|
||||||
let audioTrack = self.model.musicMode ? nil : stream.audioAsset.url
|
let audioTrack = self.model.musicMode ? nil : stream.audioAsset.url
|
||||||
|
|
||||||
client.loadFile(fileToLoad, audio: audioTrack, bitrate: stream.bitrate, kind: stream.kind, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
|
client.loadFile(fileToLoad, audio: audioTrack, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
|
||||||
self?.isLoadingVideo = true
|
self?.isLoadingVideo = true
|
||||||
self?.pause()
|
self?.pause()
|
||||||
}
|
}
|
||||||
@ -331,7 +321,7 @@ final class MPVBackend: PlayerBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if preservingTime {
|
if preservingTime {
|
||||||
if model.preservedTime.isNil || upgrading {
|
if model.preservedTime.isNil {
|
||||||
model.saveTime {
|
model.saveTime {
|
||||||
replaceItem(self.model.preservedTime)
|
replaceItem(self.model.preservedTime)
|
||||||
}
|
}
|
||||||
@ -345,20 +335,9 @@ final class MPVBackend: PlayerBackend {
|
|||||||
startClientUpdates()
|
startClientUpdates()
|
||||||
}
|
}
|
||||||
|
|
||||||
func startRefreshRateUpdates() {
|
|
||||||
refreshRateTimer.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
func stopRefreshRateUpdates() {
|
|
||||||
refreshRateTimer.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
func play() {
|
func play() {
|
||||||
#if !os(macOS)
|
isPlaying = true
|
||||||
model.setAudioSessionActive(true)
|
|
||||||
#endif
|
|
||||||
startClientUpdates()
|
startClientUpdates()
|
||||||
startRefreshRateUpdates()
|
|
||||||
|
|
||||||
if controls.presentingControls {
|
if controls.presentingControls {
|
||||||
startControlsUpdates()
|
startControlsUpdates()
|
||||||
@ -366,42 +345,18 @@ final class MPVBackend: PlayerBackend {
|
|||||||
|
|
||||||
setRate(model.currentRate)
|
setRate(model.currentRate)
|
||||||
|
|
||||||
// After the video has ended, hitting play restarts the video from the beginning.
|
|
||||||
if let currentTime, currentTime.seconds.formattedAsPlaybackTime() == model.playerTime.duration.seconds.formattedAsPlaybackTime() &&
|
|
||||||
currentTime.seconds > 0 && model.playerTime.duration.seconds > 0
|
|
||||||
{
|
|
||||||
seek(to: 0, seekType: .loopRestart)
|
|
||||||
}
|
|
||||||
|
|
||||||
client?.play()
|
client?.play()
|
||||||
|
|
||||||
isPlaying = true
|
|
||||||
isPaused = false
|
|
||||||
|
|
||||||
// Setting hasStarted to true the first time player started
|
|
||||||
if !hasStarted {
|
|
||||||
hasStarted = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func pause() {
|
func pause() {
|
||||||
#if !os(macOS)
|
isPlaying = false
|
||||||
model.setAudioSessionActive(false)
|
|
||||||
#endif
|
|
||||||
stopClientUpdates()
|
stopClientUpdates()
|
||||||
stopRefreshRateUpdates()
|
|
||||||
|
|
||||||
client?.pause()
|
client?.pause()
|
||||||
isPaused = true
|
|
||||||
isPlaying = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func togglePlay() {
|
func togglePlay() {
|
||||||
if isPlaying {
|
isPlaying ? pause() : play()
|
||||||
pause()
|
|
||||||
} else {
|
|
||||||
play()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancelLoads() {
|
func cancelLoads() {
|
||||||
@ -409,15 +364,7 @@ final class MPVBackend: PlayerBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
#if !os(macOS)
|
|
||||||
model.setAudioSessionActive(false)
|
|
||||||
#endif
|
|
||||||
stopClientUpdates()
|
|
||||||
stopRefreshRateUpdates()
|
|
||||||
client?.stop()
|
client?.stop()
|
||||||
isPlaying = false
|
|
||||||
isPaused = false
|
|
||||||
hasStarted = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func seek(to time: CMTime, seekType _: SeekType, completionHandler: ((Bool) -> Void)?) {
|
func seek(to time: CMTime, seekType _: SeekType, completionHandler: ((Bool) -> Void)?) {
|
||||||
@ -433,8 +380,8 @@ final class MPVBackend: PlayerBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func closeItem() {
|
func closeItem() {
|
||||||
pause()
|
client?.pause()
|
||||||
stop()
|
client?.stop()
|
||||||
self.video = nil
|
self.video = nil
|
||||||
self.stream = nil
|
self.stream = nil
|
||||||
}
|
}
|
||||||
@ -480,8 +427,6 @@ final class MPVBackend: PlayerBackend {
|
|||||||
timeObserverThrottle.execute {
|
timeObserverThrottle.execute {
|
||||||
self.model.updateWatch(time: self.currentTime)
|
self.model.updateWatch(time: self.currentTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
self.model.updateTime(self.currentTime!)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func stopClientUpdates() {
|
private func stopClientUpdates() {
|
||||||
@ -495,52 +440,6 @@ final class MPVBackend: PlayerBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func checkAndUpdateRefreshRate() {
|
|
||||||
guard let screenRefreshRate = client?.getScreenRefreshRate() else {
|
|
||||||
logger.warning("Failed to get screen refresh rate.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let contentFps = client?.currentContainerFps ?? screenRefreshRate
|
|
||||||
|
|
||||||
guard Defaults[.mpvSetRefreshToContentFPS] else {
|
|
||||||
// If the current refresh rate doesn't match the screen refresh rate, reset it
|
|
||||||
if client?.currentRefreshRate != screenRefreshRate {
|
|
||||||
client?.updateRefreshRate(to: screenRefreshRate)
|
|
||||||
client?.currentRefreshRate = screenRefreshRate
|
|
||||||
#if !os(macOS)
|
|
||||||
notifyViewToUpdateDisplayLink(with: screenRefreshRate)
|
|
||||||
#endif
|
|
||||||
logger.info("Reset refresh rate to screen's rate: \(screenRefreshRate) Hz")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adjust the refresh rate to match the content if it differs
|
|
||||||
if screenRefreshRate != contentFps {
|
|
||||||
client?.updateRefreshRate(to: contentFps)
|
|
||||||
client?.currentRefreshRate = contentFps
|
|
||||||
#if !os(macOS)
|
|
||||||
notifyViewToUpdateDisplayLink(with: contentFps)
|
|
||||||
#endif
|
|
||||||
logger.info("Adjusted screen refresh rate to match content: \(contentFps) Hz")
|
|
||||||
} else if client?.currentRefreshRate != screenRefreshRate {
|
|
||||||
// Ensure the refresh rate is set back to the screen's rate if no adjustment is needed
|
|
||||||
client?.updateRefreshRate(to: screenRefreshRate)
|
|
||||||
client?.currentRefreshRate = screenRefreshRate
|
|
||||||
#if !os(macOS)
|
|
||||||
notifyViewToUpdateDisplayLink(with: screenRefreshRate)
|
|
||||||
#endif
|
|
||||||
logger.info("Checked and reset refresh rate to screen's rate: \(screenRefreshRate) Hz")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if !os(macOS)
|
|
||||||
private func notifyViewToUpdateDisplayLink(with refreshRate: Int) {
|
|
||||||
NotificationCenter.default.post(name: .updateDisplayLinkFrameRate, object: nil, userInfo: ["refreshRate": refreshRate])
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
func handle(_ event: UnsafePointer<mpv_event>!) {
|
func handle(_ event: UnsafePointer<mpv_event>!) {
|
||||||
logger.info(.init(stringLiteral: "RECEIVED event: \(String(cString: mpv_event_name(event.pointee.event_id)))"))
|
logger.info(.init(stringLiteral: "RECEIVED event: \(String(cString: mpv_event_name(event.pointee.event_id)))"))
|
||||||
|
|
||||||
@ -560,13 +459,6 @@ final class MPVBackend: PlayerBackend {
|
|||||||
startClientUpdates()
|
startClientUpdates()
|
||||||
onFileLoaded = nil
|
onFileLoaded = nil
|
||||||
|
|
||||||
case MPV_EVENT_PROPERTY_CHANGE:
|
|
||||||
let dataOpaquePtr = OpaquePointer(event.pointee.data)
|
|
||||||
if let property = UnsafePointer<mpv_event_property>(dataOpaquePtr)?.pointee {
|
|
||||||
let propertyName = String(cString: property.name)
|
|
||||||
handlePropertyChange(propertyName, property)
|
|
||||||
}
|
|
||||||
|
|
||||||
case MPV_EVENT_PLAYBACK_RESTART:
|
case MPV_EVENT_PLAYBACK_RESTART:
|
||||||
isLoadingVideo = false
|
isLoadingVideo = false
|
||||||
isSeeking = false
|
isSeeking = false
|
||||||
@ -575,6 +467,17 @@ final class MPVBackend: PlayerBackend {
|
|||||||
startClientUpdates()
|
startClientUpdates()
|
||||||
onFileLoaded = nil
|
onFileLoaded = nil
|
||||||
|
|
||||||
|
case MPV_EVENT_PAUSE:
|
||||||
|
DispatchQueue.main.async { [weak self] in self?.handleEndOfFile() }
|
||||||
|
isPlaying = false
|
||||||
|
networkStateTimer.start()
|
||||||
|
|
||||||
|
case MPV_EVENT_UNPAUSE:
|
||||||
|
isPlaying = true
|
||||||
|
isLoadingVideo = false
|
||||||
|
isSeeking = false
|
||||||
|
networkStateTimer.start()
|
||||||
|
|
||||||
case MPV_EVENT_VIDEO_RECONFIG:
|
case MPV_EVENT_VIDEO_RECONFIG:
|
||||||
model.updateAspectRatio()
|
model.updateAspectRatio()
|
||||||
|
|
||||||
@ -605,6 +508,8 @@ final class MPVBackend: PlayerBackend {
|
|||||||
guard client.eofReached else {
|
guard client.eofReached else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getTimeUpdates()
|
||||||
eofPlaybackModeAction()
|
eofPlaybackModeAction()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -621,14 +526,8 @@ final class MPVBackend: PlayerBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func addSubTrack(_ url: URL) {
|
func addSubTrack(_ url: URL) {
|
||||||
Task {
|
client?.removeSubs()
|
||||||
if let areSubtitlesAdded = client?.areSubtitlesAdded {
|
client?.addSubTrack(url)
|
||||||
if await areSubtitlesAdded() {
|
|
||||||
await client?.removeSubs()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await client?.addSubTrack(url)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setVideoToAuto() {
|
func setVideoToAuto() {
|
||||||
@ -691,41 +590,4 @@ final class MPVBackend: PlayerBackend {
|
|||||||
stopMusicMode()
|
stopMusicMode()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleCaptionsChange() async {
|
|
||||||
guard let captions else {
|
|
||||||
if let isSubtitlesAdded = client?.areSubtitlesAdded, await isSubtitlesAdded() {
|
|
||||||
await client?.removeSubs()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
addSubTrack(captions.url)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handlePropertyChange(_ name: String, _ property: mpv_event_property) {
|
|
||||||
switch name {
|
|
||||||
case "pause":
|
|
||||||
if let paused = UnsafePointer<Bool>(OpaquePointer(property.data))?.pointee {
|
|
||||||
if paused {
|
|
||||||
DispatchQueue.main.async { [weak self] in self?.handleEndOfFile() }
|
|
||||||
} else {
|
|
||||||
isLoadingVideo = false
|
|
||||||
isSeeking = false
|
|
||||||
}
|
|
||||||
isPlaying = !paused
|
|
||||||
networkStateTimer.start()
|
|
||||||
}
|
|
||||||
case "core-idle":
|
|
||||||
if let idle = UnsafePointer<Bool>(OpaquePointer(property.data))?.pointee {
|
|
||||||
if !idle {
|
|
||||||
isLoadingVideo = false
|
|
||||||
isSeeking = false
|
|
||||||
networkStateTimer.start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
logger.info("MPV backend received unhandled property: \(name)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,10 @@
|
|||||||
import CoreMedia
|
import CoreMedia
|
||||||
import Defaults
|
import Defaults
|
||||||
import Foundation
|
import Foundation
|
||||||
import Libmpv
|
|
||||||
import Logging
|
import Logging
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
import Siesta
|
import Siesta
|
||||||
import UIKit
|
import UIKit
|
||||||
#else
|
|
||||||
import AppKit
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
final class MPVClient: ObservableObject {
|
final class MPVClient: ObservableObject {
|
||||||
@ -16,8 +13,6 @@ final class MPVClient: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var logger = Logger(label: "mpv-client")
|
private var logger = Logger(label: "mpv-client")
|
||||||
private var needsDrawingCooldown = false
|
|
||||||
private var needsDrawingWorkItem: DispatchWorkItem?
|
|
||||||
|
|
||||||
var mpv: OpaquePointer!
|
var mpv: OpaquePointer!
|
||||||
var mpvGL: OpaquePointer!
|
var mpvGL: OpaquePointer!
|
||||||
@ -31,7 +26,6 @@ final class MPVClient: ObservableObject {
|
|||||||
var backend: MPVBackend!
|
var backend: MPVBackend!
|
||||||
|
|
||||||
var seeking = false
|
var seeking = false
|
||||||
var currentRefreshRate = 60
|
|
||||||
|
|
||||||
func create(frame: CGRect? = nil) {
|
func create(frame: CGRect? = nil) {
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
@ -42,7 +36,7 @@ final class MPVClient: ObservableObject {
|
|||||||
|
|
||||||
mpv = mpv_create()
|
mpv = mpv_create()
|
||||||
if mpv == nil {
|
if mpv == nil {
|
||||||
logger.critical("failed creating context\n")
|
print("failed creating context\n")
|
||||||
exit(1)
|
exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,81 +59,24 @@ final class MPVClient: ObservableObject {
|
|||||||
checkError(mpv_set_option_string(mpv, "input-media-keys", "yes"))
|
checkError(mpv_set_option_string(mpv, "input-media-keys", "yes"))
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// CACHING //
|
checkError(mpv_set_option_string(mpv, "cache-pause-initial", "yes"))
|
||||||
|
|
||||||
checkError(mpv_set_option_string(mpv, "cache-pause-initial", Defaults[.mpvCachePauseInital] ? "yes" : "no"))
|
|
||||||
checkError(mpv_set_option_string(mpv, "cache-secs", Defaults[.mpvCacheSecs]))
|
checkError(mpv_set_option_string(mpv, "cache-secs", Defaults[.mpvCacheSecs]))
|
||||||
checkError(mpv_set_option_string(mpv, "cache-pause-wait", Defaults[.mpvCachePauseWait]))
|
checkError(mpv_set_option_string(mpv, "cache-pause-wait", Defaults[.mpvCachePauseWait]))
|
||||||
|
|
||||||
// PLAYBACK //
|
|
||||||
checkError(mpv_set_option_string(mpv, "keep-open", "yes"))
|
checkError(mpv_set_option_string(mpv, "keep-open", "yes"))
|
||||||
checkError(mpv_set_option_string(mpv, "deinterlace", Defaults[.mpvDeinterlace] ? "yes" : "no"))
|
checkError(mpv_set_option_string(mpv, "hwdec", machine == "x86_64" ? "no" : "auto-safe"))
|
||||||
checkError(mpv_set_option_string(mpv, "sub-scale", Defaults[.captionsFontScaleSize]))
|
|
||||||
checkError(mpv_set_option_string(mpv, "sub-color", Defaults[.captionsFontColor]))
|
|
||||||
checkError(mpv_set_option_string(mpv, "user-agent", UserAgentManager.shared.userAgent))
|
|
||||||
checkError(mpv_set_option_string(mpv, "initial-audio-sync", Defaults[.mpvInitialAudioSync] ? "yes" : "no"))
|
|
||||||
|
|
||||||
// Enable VSYNC – needed for `video-sync`
|
|
||||||
if Defaults[.mpvSetRefreshToContentFPS] {
|
|
||||||
checkError(mpv_set_option_string(mpv, "opengl-swapinterval", "1"))
|
|
||||||
checkError(mpv_set_option_string(mpv, "video-sync", "display-resample"))
|
|
||||||
checkError(mpv_set_option_string(mpv, "interpolation", "yes"))
|
|
||||||
checkError(mpv_set_option_string(mpv, "tscale", "mitchell"))
|
|
||||||
checkError(mpv_set_option_string(mpv, "tscale-window", "blackman"))
|
|
||||||
checkError(mpv_set_option_string(mpv, "vd-lavc-framedrop", "nonref"))
|
|
||||||
checkError(mpv_set_option_string(mpv, "display-fps-override", "\(String(getScreenRefreshRate()))"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// CPU //
|
|
||||||
|
|
||||||
// Determine number of threads based on system core count
|
|
||||||
let numberOfCores = ProcessInfo.processInfo.processorCount
|
|
||||||
let threads = numberOfCores * 2
|
|
||||||
|
|
||||||
// Log the number of cores and threads
|
|
||||||
logger.info("Number of CPU cores: \(numberOfCores)")
|
|
||||||
|
|
||||||
// Set the number of threads dynamically
|
|
||||||
checkError(mpv_set_option_string(mpv, "vd-lavc-threads", "\(threads)"))
|
|
||||||
|
|
||||||
// GPU //
|
|
||||||
|
|
||||||
checkError(mpv_set_option_string(mpv, "hwdec", Defaults[.mpvHWdec]))
|
|
||||||
checkError(mpv_set_option_string(mpv, "vo", "libmpv"))
|
checkError(mpv_set_option_string(mpv, "vo", "libmpv"))
|
||||||
|
|
||||||
// We set set everything to OpenGL so MPV doesn't have to probe for other APIs.
|
|
||||||
checkError(mpv_set_option_string(mpv, "gpu-api", "opengl"))
|
|
||||||
|
|
||||||
#if !os(macOS)
|
|
||||||
checkError(mpv_set_option_string(mpv, "opengl-es", "yes"))
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// We set this to ordered since we use OpenGL and Apple's implementation is ancient.
|
|
||||||
checkError(mpv_set_option_string(mpv, "dither", "ordered"))
|
|
||||||
|
|
||||||
// DEMUXER //
|
|
||||||
|
|
||||||
// We request to test for lavf first and skip probing other demuxer.
|
|
||||||
checkError(mpv_set_option_string(mpv, "demuxer", "lavf"))
|
|
||||||
checkError(mpv_set_option_string(mpv, "audio-demuxer", "lavf"))
|
|
||||||
checkError(mpv_set_option_string(mpv, "sub-demuxer", "lavf"))
|
|
||||||
checkError(mpv_set_option_string(mpv, "demuxer-lavf-analyzeduration", "1"))
|
checkError(mpv_set_option_string(mpv, "demuxer-lavf-analyzeduration", "1"))
|
||||||
checkError(mpv_set_option_string(mpv, "demuxer-lavf-probe-info", Defaults[.mpvDemuxerLavfProbeInfo]))
|
|
||||||
|
|
||||||
// Disable ytdl, since it causes crashes on macOS.
|
|
||||||
#if os(macOS)
|
|
||||||
checkError(mpv_set_option_string(mpv, "ytdl", "no"))
|
|
||||||
#endif
|
|
||||||
|
|
||||||
checkError(mpv_initialize(mpv))
|
checkError(mpv_initialize(mpv))
|
||||||
|
|
||||||
let api = UnsafeMutableRawPointer(mutating: (MPV_RENDER_API_TYPE_OPENGL as NSString).utf8String)
|
let api = UnsafeMutableRawPointer(mutating: (MPV_RENDER_API_TYPE_OPENGL as NSString).utf8String)
|
||||||
var initParams = mpv_opengl_init_params(
|
var initParams = mpv_opengl_init_params(
|
||||||
get_proc_address: getProcAddress,
|
get_proc_address: getProcAddress,
|
||||||
get_proc_address_ctx: nil
|
get_proc_address_ctx: nil,
|
||||||
|
extra_exts: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
queue = DispatchQueue(label: "mpv", qos: .userInteractive, attributes: [.concurrent])
|
queue = DispatchQueue(label: "mpv")
|
||||||
|
|
||||||
withUnsafeMutablePointer(to: &initParams) { initParams in
|
withUnsafeMutablePointer(to: &initParams) { initParams in
|
||||||
var params = [
|
var params = [
|
||||||
@ -149,7 +86,7 @@ final class MPVClient: ObservableObject {
|
|||||||
]
|
]
|
||||||
|
|
||||||
if mpv_render_context_create(&mpvGL, mpv, ¶ms) < 0 {
|
if mpv_render_context_create(&mpvGL, mpv, ¶ms) < 0 {
|
||||||
logger.critical("failed to initialize mpv GL context")
|
puts("failed to initialize mpv GL context")
|
||||||
exit(1)
|
exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -170,9 +107,9 @@ final class MPVClient: ObservableObject {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
mpv_set_wakeup_callback(mpv, wakeUp, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()))
|
queue!.async {
|
||||||
mpv_observe_property(mpv, 0, "pause", MPV_FORMAT_FLAG)
|
mpv_set_wakeup_callback(self.mpv, wakeUp, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()))
|
||||||
mpv_observe_property(mpv, 0, "core-idle", MPV_FORMAT_FLAG)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func readEvents() {
|
func readEvents() {
|
||||||
@ -190,8 +127,6 @@ final class MPVClient: ObservableObject {
|
|||||||
func loadFile(
|
func loadFile(
|
||||||
_ url: URL,
|
_ url: URL,
|
||||||
audio: URL? = nil,
|
audio: URL? = nil,
|
||||||
bitrate: Int? = nil,
|
|
||||||
kind: Stream.Kind,
|
|
||||||
sub: URL? = nil,
|
sub: URL? = nil,
|
||||||
time: CMTime? = nil,
|
time: CMTime? = nil,
|
||||||
forceSeekable: Bool = false,
|
forceSeekable: Bool = false,
|
||||||
@ -202,10 +137,6 @@ final class MPVClient: ObservableObject {
|
|||||||
|
|
||||||
args.append("replace")
|
args.append("replace")
|
||||||
|
|
||||||
// needed since mpvkit 0.38.0
|
|
||||||
// https://github.com/mpv-player/mpv/issues/13806#issuecomment-2029818905
|
|
||||||
args.append("-1")
|
|
||||||
|
|
||||||
if let time, time.seconds > 0 {
|
if let time, time.seconds > 0 {
|
||||||
options.append("start=\(Int(time.seconds))")
|
options.append("start=\(Int(time.seconds))")
|
||||||
}
|
}
|
||||||
@ -228,10 +159,6 @@ final class MPVClient: ObservableObject {
|
|||||||
args.append(options.joined(separator: ","))
|
args.append(options.joined(separator: ","))
|
||||||
}
|
}
|
||||||
|
|
||||||
if kind == .hls, bitrate != 0 {
|
|
||||||
checkError(mpv_set_option_string(mpv, "hls-bitrate", String(describing: bitrate)))
|
|
||||||
}
|
|
||||||
|
|
||||||
command("loadfile", args: args, returnValueCallback: completionHandler)
|
command("loadfile", args: args, returnValueCallback: completionHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -345,31 +272,6 @@ final class MPVClient: ObservableObject {
|
|||||||
mpv.isNil ? false : getFlag("eof-reached")
|
mpv.isNil ? false : getFlag("eof-reached")
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentContainerFps: Int {
|
|
||||||
guard !mpv.isNil else { return 30 }
|
|
||||||
let fps = getDouble("container-fps")
|
|
||||||
return Int(fps.rounded())
|
|
||||||
}
|
|
||||||
|
|
||||||
func areSubtitlesAdded() async -> Bool {
|
|
||||||
guard !mpv.isNil else { return false }
|
|
||||||
|
|
||||||
let trackCount = await Task(operation: { getInt("track-list/count") }).value
|
|
||||||
guard trackCount > 0 else { return false }
|
|
||||||
|
|
||||||
for index in 0 ..< trackCount {
|
|
||||||
if let trackType = await Task(operation: { getString("track-list/\(index)/type") }).value, trackType == "sub" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func logCurrentFps() {
|
|
||||||
let fps = currentContainerFps
|
|
||||||
logger.info("Current container FPS: \(fps)")
|
|
||||||
}
|
|
||||||
|
|
||||||
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
|
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
|
||||||
guard !seeking else {
|
guard !seeking else {
|
||||||
logger.warning("ignoring seek, another in progress")
|
logger.warning("ignoring seek, another in progress")
|
||||||
@ -413,7 +315,7 @@ final class MPVClient: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
DispatchQueue.main.async(qos: .userInteractive) { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
let model = self.backend.model
|
let model = self.backend.model
|
||||||
let aspectRatio = self.aspectRatio > 0 && self.aspectRatio < VideoPlayerView.defaultAspectRatio ? self.aspectRatio : VideoPlayerView.defaultAspectRatio
|
let aspectRatio = self.aspectRatio > 0 && self.aspectRatio < VideoPlayerView.defaultAspectRatio ? self.aspectRatio : VideoPlayerView.defaultAspectRatio
|
||||||
@ -441,30 +343,10 @@ final class MPVClient: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func setNeedsDrawing(_ needsDrawing: Bool) {
|
func setNeedsDrawing(_ needsDrawing: Bool) {
|
||||||
// Check if we are currently in a cooldown period
|
|
||||||
guard !needsDrawingCooldown else {
|
|
||||||
logger.info("Not drawing, cooldown in progress")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("needs drawing: \(needsDrawing)")
|
logger.info("needs drawing: \(needsDrawing)")
|
||||||
|
|
||||||
// Set the cooldown flag to true and cancel any existing work item
|
|
||||||
needsDrawingCooldown = true
|
|
||||||
needsDrawingWorkItem?.cancel()
|
|
||||||
|
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
glView?.needsDrawing = needsDrawing
|
glView?.needsDrawing = needsDrawing
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Create a new DispatchWorkItem to reset the cooldown flag after 0.1 seconds
|
|
||||||
let workItem = DispatchWorkItem { [weak self] in
|
|
||||||
self?.needsDrawingCooldown = false
|
|
||||||
}
|
|
||||||
needsDrawingWorkItem = workItem
|
|
||||||
|
|
||||||
// Schedule the cooldown reset after 0.1 seconds
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: workItem)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func command(
|
func command(
|
||||||
@ -492,59 +374,16 @@ final class MPVClient: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateRefreshRate(to refreshRate: Int) {
|
|
||||||
setString("display-fps-override", "\(String(refreshRate))")
|
|
||||||
logger.info("Updated refresh rate during playback to: \(refreshRate) Hz")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retrieve the screen's current refresh rate dynamically.
|
|
||||||
func getScreenRefreshRate() -> Int {
|
|
||||||
var refreshRate = 60 // Default to 60 Hz in case of failure
|
|
||||||
|
|
||||||
#if os(macOS)
|
|
||||||
// macOS implementation using NSScreen
|
|
||||||
if let screen = NSScreen.main,
|
|
||||||
let displayID = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID,
|
|
||||||
let mode = CGDisplayCopyDisplayMode(displayID),
|
|
||||||
mode.refreshRate > 0
|
|
||||||
{
|
|
||||||
refreshRate = Int(mode.refreshRate)
|
|
||||||
logger.info("Screen refresh rate: \(refreshRate) Hz")
|
|
||||||
} else {
|
|
||||||
logger.warning("Failed to get refresh rate from NSScreen.")
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
// iOS implementation using UIScreen with a failover
|
|
||||||
let mainScreen = UIScreen.main
|
|
||||||
refreshRate = mainScreen.maximumFramesPerSecond
|
|
||||||
|
|
||||||
// Failover: if maximumFramesPerSecond is 0 or an unexpected value
|
|
||||||
if refreshRate <= 0 {
|
|
||||||
refreshRate = 60 // Fallback to 60 Hz
|
|
||||||
logger.warning("Failed to get refresh rate from UIScreen, falling back to 60 Hz.")
|
|
||||||
} else {
|
|
||||||
logger.info("Screen refresh rate: \(refreshRate) Hz")
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
currentRefreshRate = refreshRate
|
|
||||||
return refreshRate
|
|
||||||
}
|
|
||||||
|
|
||||||
func addVideoTrack(_ url: URL) {
|
func addVideoTrack(_ url: URL) {
|
||||||
command("video-add", args: [url.absoluteString])
|
command("video-add", args: [url.absoluteString])
|
||||||
}
|
}
|
||||||
|
|
||||||
func addSubTrack(_ url: URL) async {
|
func addSubTrack(_ url: URL) {
|
||||||
await Task {
|
|
||||||
command("sub-add", args: [url.absoluteString])
|
command("sub-add", args: [url.absoluteString])
|
||||||
}.value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeSubs() async {
|
func removeSubs() {
|
||||||
await Task {
|
|
||||||
command("sub-remove")
|
command("sub-remove")
|
||||||
}.value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setVideoToAuto() {
|
func setVideoToAuto() {
|
||||||
@ -555,22 +394,6 @@ final class MPVClient: ObservableObject {
|
|||||||
setString("video", "no")
|
setString("video", "no")
|
||||||
}
|
}
|
||||||
|
|
||||||
func setSubToAuto() {
|
|
||||||
setString("sub", "auto")
|
|
||||||
}
|
|
||||||
|
|
||||||
func setSubToNo() {
|
|
||||||
setString("sub", "no")
|
|
||||||
}
|
|
||||||
|
|
||||||
func setSubFontSize(scaleSize: String) {
|
|
||||||
setString("sub-scale", scaleSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setSubFontColor(color: String) {
|
|
||||||
setString("sub-color", color)
|
|
||||||
}
|
|
||||||
|
|
||||||
var tracksCount: Int {
|
var tracksCount: Int {
|
||||||
Int(getString("track-list/count") ?? "-1") ?? -1
|
Int(getString("track-list/count") ?? "-1") ?? -1
|
||||||
}
|
}
|
||||||
@ -608,7 +431,6 @@ final class MPVClient: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getString(_ name: String) -> String? {
|
func getString(_ name: String) -> String? {
|
||||||
guard mpv != nil else { return nil }
|
|
||||||
let cstr = mpv_get_property_string(mpv, name)
|
let cstr = mpv_get_property_string(mpv, name)
|
||||||
let str: String? = cstr == nil ? nil : String(cString: cstr!)
|
let str: String? = cstr == nil ? nil : String(cString: cstr!)
|
||||||
mpv_free(cstr)
|
mpv_free(cstr)
|
||||||
@ -649,10 +471,11 @@ final class MPVClient: ObservableObject {
|
|||||||
let data = Data(bufPtr)
|
let data = Data(bufPtr)
|
||||||
if let lastIndex = data.lastIndex(where: { $0 != 0 }) {
|
if let lastIndex = data.lastIndex(where: { $0 != 0 }) {
|
||||||
return String(data: data[0 ... lastIndex], encoding: .isoLatin1)!
|
return String(data: data[0 ... lastIndex], encoding: .isoLatin1)!
|
||||||
}
|
} else {
|
||||||
return String(data: data, encoding: .isoLatin1)!
|
return String(data: data, encoding: .isoLatin1)!
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import CoreMedia
|
import CoreMedia
|
||||||
import Defaults
|
import Defaults
|
||||||
import Foundation
|
import Foundation
|
||||||
import Logging
|
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
#endif
|
||||||
@ -20,8 +19,6 @@ protocol PlayerBackend {
|
|||||||
var loadedVideo: Bool { get }
|
var loadedVideo: Bool { get }
|
||||||
var isLoadingVideo: Bool { get }
|
var isLoadingVideo: Bool { get }
|
||||||
|
|
||||||
var hasStarted: Bool { get }
|
|
||||||
var isPaused: Bool { get }
|
|
||||||
var isPlaying: Bool { get }
|
var isPlaying: Bool { get }
|
||||||
var isSeeking: Bool { get }
|
var isSeeking: Bool { get }
|
||||||
var playerItemDuration: CMTime? { get }
|
var playerItemDuration: CMTime? { get }
|
||||||
@ -32,6 +29,7 @@ protocol PlayerBackend {
|
|||||||
var videoWidth: Double? { get }
|
var videoWidth: Double? { get }
|
||||||
var videoHeight: Double? { get }
|
var videoHeight: Double? { get }
|
||||||
|
|
||||||
|
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream?
|
||||||
func canPlay(_ stream: Stream) -> Bool
|
func canPlay(_ stream: Stream) -> Bool
|
||||||
func canPlayAtRate(_ rate: Double) -> Bool
|
func canPlayAtRate(_ rate: Double) -> Bool
|
||||||
|
|
||||||
@ -76,10 +74,6 @@ protocol PlayerBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension PlayerBackend {
|
extension PlayerBackend {
|
||||||
var logger: Logger {
|
|
||||||
return Logger(label: "stream.yattee.player.backend")
|
|
||||||
}
|
|
||||||
|
|
||||||
func seek(to time: CMTime, seekType: SeekType, completionHandler: ((Bool) -> Void)? = nil) {
|
func seek(to time: CMTime, seekType: SeekType, completionHandler: ((Bool) -> Void)? = nil) {
|
||||||
model.seek.registerSeek(at: time, type: seekType, restore: currentTime)
|
model.seek.registerSeek(at: time, type: seekType, restore: currentTime)
|
||||||
seek(to: time, seekType: seekType, completionHandler: completionHandler)
|
seek(to: time, seekType: seekType, completionHandler: completionHandler)
|
||||||
@ -116,22 +110,15 @@ extension PlayerBackend {
|
|||||||
model.prepareCurrentItemForHistory(finished: true)
|
model.prepareCurrentItemForHistory(finished: true)
|
||||||
|
|
||||||
if model.queue.isEmpty {
|
if model.queue.isEmpty {
|
||||||
#if os(tvOS)
|
|
||||||
if Defaults[.closeVideoOnEOF] {
|
if Defaults[.closeVideoOnEOF] {
|
||||||
|
#if os(tvOS)
|
||||||
if model.activeBackend == .appleAVPlayer {
|
if model.activeBackend == .appleAVPlayer {
|
||||||
model.avPlayerBackend.controller?.dismiss(animated: false)
|
model.avPlayerBackend.controller?.dismiss(animated: false)
|
||||||
}
|
}
|
||||||
model.resetQueue()
|
|
||||||
model.hide()
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
if Defaults[.closeVideoOnEOF] {
|
|
||||||
model.resetQueue()
|
|
||||||
model.hide()
|
|
||||||
} else if Defaults[.exitFullscreenOnEOF], model.playingFullScreen {
|
|
||||||
model.exitFullScreen()
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
|
model.resetQueue()
|
||||||
|
model.hide()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
model.advanceToNextItem()
|
model.advanceToNextItem()
|
||||||
}
|
}
|
||||||
@ -144,91 +131,11 @@ extension PlayerBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting, formatOrder: [QualityProfile.Format]) -> Stream? {
|
|
||||||
logger.info("Starting bestPlayable function")
|
|
||||||
logger.info("Total streams received: \(streams.count)")
|
|
||||||
logger.info("Max resolution allowed: \(String(describing: maxResolution.value))")
|
|
||||||
logger.info("Format order: \(formatOrder)")
|
|
||||||
|
|
||||||
// Filter out non-HLS streams and streams with resolution more than maxResolution
|
|
||||||
let nonHLSStreams = streams.filter {
|
|
||||||
let isHLS = $0.kind == .hls
|
|
||||||
// Check if the stream's resolution is within the maximum allowed resolution
|
|
||||||
let isWithinResolution = $0.resolution.map { $0 <= maxResolution.value } ?? false
|
|
||||||
|
|
||||||
logger.info("Stream ID: \($0.id) - Kind: \(String(describing: $0.kind)) - Resolution: \(String(describing: $0.resolution)) - Bitrate: \($0.bitrate ?? 0)")
|
|
||||||
logger.info("Is HLS: \(isHLS), Is within resolution: \(isWithinResolution)")
|
|
||||||
return !isHLS && isWithinResolution
|
|
||||||
}
|
|
||||||
logger.info("Non-HLS streams after filtering: \(nonHLSStreams.count)")
|
|
||||||
|
|
||||||
// Find max resolution and bitrate from non-HLS streams
|
|
||||||
let bestResolutionStream = nonHLSStreams.max { $0.resolution < $1.resolution }
|
|
||||||
let bestBitrateStream = nonHLSStreams.max { $0.bitrate ?? 0 < $1.bitrate ?? 0 }
|
|
||||||
|
|
||||||
logger.info("Best resolution stream: \(String(describing: bestResolutionStream?.id)) with resolution: \(String(describing: bestResolutionStream?.resolution))")
|
|
||||||
logger.info("Best bitrate stream: \(String(describing: bestBitrateStream?.id)) with bitrate: \(String(describing: bestBitrateStream?.bitrate))")
|
|
||||||
|
|
||||||
let bestResolution = bestResolutionStream?.resolution ?? maxResolution.value
|
|
||||||
let bestBitrate = bestBitrateStream?.bitrate ?? bestResolutionStream?.resolution.bitrate ?? maxResolution.value.bitrate
|
|
||||||
|
|
||||||
logger.info("Final best resolution selected: \(String(describing: bestResolution))")
|
|
||||||
logger.info("Final best bitrate selected: \(bestBitrate)")
|
|
||||||
|
|
||||||
let adjustedStreams = streams.map { stream in
|
|
||||||
if stream.kind == .hls {
|
|
||||||
logger.info("Adjusting HLS stream ID: \(stream.id)")
|
|
||||||
stream.resolution = bestResolution
|
|
||||||
stream.bitrate = bestBitrate
|
|
||||||
stream.format = .hls
|
|
||||||
} else if stream.kind == .stream {
|
|
||||||
logger.info("Adjusting non-HLS stream ID: \(stream.id)")
|
|
||||||
stream.format = .stream
|
|
||||||
}
|
|
||||||
return stream
|
|
||||||
}
|
|
||||||
|
|
||||||
let filteredStreams = adjustedStreams.filter { stream in
|
|
||||||
// Check if the stream's resolution is within the maximum allowed resolution
|
|
||||||
let isWithinResolution = stream.resolution <= maxResolution.value
|
|
||||||
logger.info("Filtered stream ID: \(stream.id) - Is within max resolution: \(isWithinResolution)")
|
|
||||||
return isWithinResolution
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("Filtered streams count after adjustments: \(filteredStreams.count)")
|
|
||||||
|
|
||||||
let bestStream = filteredStreams.max { lhs, rhs in
|
|
||||||
if lhs.resolution == rhs.resolution {
|
|
||||||
guard let lhsFormat = QualityProfile.Format(rawValue: lhs.format.rawValue),
|
|
||||||
let rhsFormat = QualityProfile.Format(rawValue: rhs.format.rawValue)
|
|
||||||
else {
|
|
||||||
logger.info("Failed to extract lhsFormat or rhsFormat for streams \(lhs.id) and \(rhs.id)")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
let lhsFormatIndex = formatOrder.firstIndex(of: lhsFormat) ?? Int.max
|
|
||||||
let rhsFormatIndex = formatOrder.firstIndex(of: rhsFormat) ?? Int.max
|
|
||||||
|
|
||||||
logger.info("Comparing formats for streams \(lhs.id) and \(rhs.id) - LHS Format Index: \(lhsFormatIndex), RHS Format Index: \(rhsFormatIndex)")
|
|
||||||
|
|
||||||
return lhsFormatIndex > rhsFormatIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("Comparing resolutions for streams \(lhs.id) and \(rhs.id) - LHS Resolution: \(String(describing: lhs.resolution)), RHS Resolution: \(String(describing: rhs.resolution))")
|
|
||||||
|
|
||||||
return lhs.resolution < rhs.resolution
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("Best stream selected: \(String(describing: bestStream?.id)) with resolution: \(String(describing: bestStream?.resolution)) and format: \(String(describing: bestStream?.format))")
|
|
||||||
|
|
||||||
return bestStream
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateControls(completionHandler: (() -> Void)? = nil) {
|
func updateControls(completionHandler: (() -> Void)? = nil) {
|
||||||
logger.info("updating controls")
|
print("updating controls")
|
||||||
|
|
||||||
guard model.presentingPlayer, !model.controls.presentingOverlays else {
|
guard model.presentingPlayer, !model.controls.presentingOverlays else {
|
||||||
logger.info("ignored controls update")
|
print("ignored controls update")
|
||||||
completionHandler?()
|
completionHandler?()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -236,7 +143,7 @@ extension PlayerBackend {
|
|||||||
DispatchQueue.main.async(qos: .userInteractive) {
|
DispatchQueue.main.async(qos: .userInteractive) {
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
guard UIApplication.shared.applicationState != .background else {
|
guard UIApplication.shared.applicationState != .background else {
|
||||||
logger.info("not performing controls updates in background")
|
print("not performing controls updates in background")
|
||||||
completionHandler?()
|
completionHandler?()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -14,7 +14,7 @@ final class PlayerControlsModel: ObservableObject {
|
|||||||
var timer: Timer?
|
var timer: Timer?
|
||||||
|
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
private(set) var reporter = PassthroughSubject<String, Never>() // swiftlint:disable:this private_subject
|
private(set) var reporter = PassthroughSubject<String, Never>()
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
var player: PlayerModel! { .shared }
|
var player: PlayerModel! { .shared }
|
||||||
@ -106,11 +106,7 @@ final class PlayerControlsModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func toggle() {
|
func toggle() {
|
||||||
if presentingControls {
|
presentingControls ? hide() : show()
|
||||||
hide()
|
|
||||||
} else {
|
|
||||||
show()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func resetTimer() {
|
func resetTimer() {
|
||||||
|
@ -47,7 +47,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
|
|
||||||
static var shared = PlayerModel()
|
static var shared = PlayerModel()
|
||||||
|
|
||||||
let logger = Logger(label: "stream.yattee.player.model")
|
let logger = Logger(label: "stream.yattee.app")
|
||||||
|
|
||||||
var playerItem: AVPlayerItem?
|
var playerItem: AVPlayerItem?
|
||||||
|
|
||||||
@ -76,8 +76,6 @@ final class PlayerModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var previousActiveBackend: PlayerBackendType?
|
|
||||||
|
|
||||||
lazy var playerBackendView = PlayerBackendView()
|
lazy var playerBackendView = PlayerBackendView()
|
||||||
|
|
||||||
@Published var playerSize: CGSize = .zero { didSet {
|
@Published var playerSize: CGSize = .zero { didSet {
|
||||||
@ -90,7 +88,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
}}
|
}}
|
||||||
@Published var aspectRatio = VideoPlayerView.defaultAspectRatio
|
@Published var aspectRatio = VideoPlayerView.defaultAspectRatio
|
||||||
@Published var stream: Stream?
|
@Published var stream: Stream?
|
||||||
@Published var currentRate = 1.0 { didSet { handleCurrentRateChange() } }
|
@Published var currentRate: Double = 1.0 { didSet { handleCurrentRateChange() } }
|
||||||
|
|
||||||
@Published var qualityProfileSelection: QualityProfile? { didSet { handleQualityProfileChange() } }
|
@Published var qualityProfileSelection: QualityProfile? { didSet { handleQualityProfileChange() } }
|
||||||
|
|
||||||
@ -130,19 +128,9 @@ final class PlayerModel: ObservableObject {
|
|||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@Published var lockedOrientation: UIInterfaceOrientationMask?
|
@Published var lockedOrientation: UIInterfaceOrientationMask?
|
||||||
@Published var isOrientationLocked: Bool {
|
@Default(.rotateToLandscapeOnEnterFullScreen) private var rotateToLandscapeOnEnterFullScreen
|
||||||
didSet {
|
|
||||||
Defaults[.isOrientationLocked] = isOrientationLocked
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Default(.rotateToLandscapeOnEnterFullScreen) var rotateToLandscapeOnEnterFullScreen
|
|
||||||
@Default(.lockPortraitWhenBrowsing) var lockPortraitWhenBrowsing
|
|
||||||
var fullscreenInitiatedByButton = false
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@Published var currentChapterIndex: Int?
|
|
||||||
|
|
||||||
var accounts: AccountsModel { .shared }
|
var accounts: AccountsModel { .shared }
|
||||||
var comments: CommentsModel { .shared }
|
var comments: CommentsModel { .shared }
|
||||||
var controls: PlayerControlsModel { .shared }
|
var controls: PlayerControlsModel { .shared }
|
||||||
@ -187,11 +175,6 @@ final class PlayerModel: ObservableObject {
|
|||||||
@Default(.playerRate) var playerRate
|
@Default(.playerRate) var playerRate
|
||||||
@Default(.systemControlsSeekDuration) var systemControlsSeekDuration
|
@Default(.systemControlsSeekDuration) var systemControlsSeekDuration
|
||||||
|
|
||||||
#if os(macOS)
|
|
||||||
@Default(.buttonBackwardSeekDuration) private var buttonBackwardSeekDuration
|
|
||||||
@Default(.buttonForwardSeekDuration) private var buttonForwardSeekDuration
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
@Default(.closePiPAndOpenPlayerOnEnteringForeground) var closePiPAndOpenPlayerOnEnteringForeground
|
@Default(.closePiPAndOpenPlayerOnEnteringForeground) var closePiPAndOpenPlayerOnEnteringForeground
|
||||||
#endif
|
#endif
|
||||||
@ -203,43 +186,10 @@ final class PlayerModel: ObservableObject {
|
|||||||
var rateToRestore: Float?
|
var rateToRestore: Float?
|
||||||
private var remoteCommandCenterConfigured = false
|
private var remoteCommandCenterConfigured = false
|
||||||
|
|
||||||
// Used in the PlayerModel extension in PlayerQueue
|
|
||||||
var retryAttempts = [String: Int]()
|
|
||||||
|
|
||||||
#if os(macOS)
|
|
||||||
var keyPressMonitor: Any?
|
|
||||||
#endif
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
#if os(iOS)
|
|
||||||
isOrientationLocked = Defaults[.isOrientationLocked]
|
|
||||||
|
|
||||||
if isOrientationLocked, lockPortraitWhenBrowsing {
|
|
||||||
lockedOrientation = UIInterfaceOrientationMask.portrait
|
|
||||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
|
||||||
} else if isOrientationLocked {
|
|
||||||
lockOrientationAction()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
mpvBackend.controller = mpvController
|
mpvBackend.controller = mpvController
|
||||||
mpvBackend.client = mpvController.client
|
mpvBackend.client = mpvController.client
|
||||||
|
|
||||||
// Register for audio session interruption notifications
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self,
|
|
||||||
selector: #selector(handleAudioSessionInterruption(_:)),
|
|
||||||
name: AVAudioSession.interruptionNotification,
|
|
||||||
object: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
// Register for audio session route change notifications
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self,
|
|
||||||
selector: #selector(handleRouteChange(_:)),
|
|
||||||
name: AVAudioSession.routeChangeNotification,
|
|
||||||
object: AVAudioSession.sharedInstance()
|
|
||||||
)
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
playbackMode = Defaults[.playbackMode]
|
playbackMode = Defaults[.playbackMode]
|
||||||
@ -256,25 +206,10 @@ final class PlayerModel: ObservableObject {
|
|||||||
currentRate = playerRate
|
currentRate = playerRate
|
||||||
}
|
}
|
||||||
|
|
||||||
#if !os(macOS)
|
|
||||||
deinit {
|
|
||||||
NotificationCenter.default.removeObserver(
|
|
||||||
self, name: AVAudioSession.interruptionNotification, object: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
NotificationCenter.default.removeObserver(
|
|
||||||
self,
|
|
||||||
name: AVAudioSession.routeChangeNotification,
|
|
||||||
object: AVAudioSession.sharedInstance()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
func show() {
|
func show() {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
if presentingPlayer {
|
if presentingPlayer {
|
||||||
Windows.player.focus()
|
Windows.player.focus()
|
||||||
assignKeyPressMonitor()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@ -290,7 +225,6 @@ final class PlayerModel: ObservableObject {
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
Windows.player.open()
|
Windows.player.open()
|
||||||
Windows.player.focus()
|
Windows.player.focus()
|
||||||
assignKeyPressMonitor()
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -310,7 +244,6 @@ final class PlayerModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
destroyKeyPressMonitor()
|
|
||||||
Windows.player.hide()
|
Windows.player.hide()
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@ -349,14 +282,6 @@ final class PlayerModel: ObservableObject {
|
|||||||
backend.isPlaying
|
backend.isPlaying
|
||||||
}
|
}
|
||||||
|
|
||||||
var isPaused: Bool {
|
|
||||||
backend.isPaused
|
|
||||||
}
|
|
||||||
|
|
||||||
var hasStarted: Bool {
|
|
||||||
backend.hasStarted
|
|
||||||
}
|
|
||||||
|
|
||||||
var playerItemDuration: CMTime? {
|
var playerItemDuration: CMTime? {
|
||||||
guard !currentItem.isNil else {
|
guard !currentItem.isNil else {
|
||||||
return nil
|
return nil
|
||||||
@ -553,10 +478,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handlePresentationChange() {
|
private func handlePresentationChange() {
|
||||||
#if os(macOS)
|
|
||||||
// TODO: Check whether this is needed on macOS
|
|
||||||
backend.setNeedsDrawing(presentingPlayer)
|
backend.setNeedsDrawing(presentingPlayer)
|
||||||
#endif
|
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
if presentingPlayer, activeBackend == .appleAVPlayer, avPlayerUsesSystemControls, Constants.isIPhone {
|
if presentingPlayer, activeBackend == .appleAVPlayer, avPlayerUsesSystemControls, Constants.isIPhone {
|
||||||
@ -585,16 +507,18 @@ final class PlayerModel: ObservableObject {
|
|||||||
|
|
||||||
if !presentingPlayer {
|
if !presentingPlayer {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
if lockPortraitWhenBrowsing {
|
if Defaults[.lockPortraitWhenBrowsing] {
|
||||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||||
} else {
|
} else {
|
||||||
Orientation.lockOrientation(.all)
|
Orientation.lockOrientation(.allButUpsideDown)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OrientationModel.shared.stopOrientationUpdates()
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func changeActiveBackend(from: PlayerBackendType, to: PlayerBackendType, changingStream: Bool = true, isInClosePip: Bool = false) {
|
func changeActiveBackend(from: PlayerBackendType, to: PlayerBackendType, changingStream: Bool = true) {
|
||||||
guard activeBackend != to else {
|
guard activeBackend != to else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -603,7 +527,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
|
|
||||||
let wasPlaying = isPlaying
|
let wasPlaying = isPlaying
|
||||||
|
|
||||||
if to == .mpv && !isInClosePip {
|
if to == .mpv {
|
||||||
closePiP()
|
closePiP()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -696,65 +620,60 @@ final class PlayerModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func closeCurrentItem(finished: Bool = false) {
|
func closeCurrentItem(finished: Bool = false) {
|
||||||
guard !closing else { return }
|
|
||||||
closing = true
|
|
||||||
|
|
||||||
if playingFullScreen { exitFullScreen() }
|
|
||||||
|
|
||||||
Delay.by(0.3) { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
pause()
|
pause()
|
||||||
videoBeingOpened = nil
|
videoBeingOpened = nil
|
||||||
advancing = false
|
advancing = false
|
||||||
forceBackendOnPlay = nil
|
forceBackendOnPlay = nil
|
||||||
|
|
||||||
|
closing = true
|
||||||
controls.presentingControls = false
|
controls.presentingControls = false
|
||||||
|
|
||||||
self.prepareCurrentItemForHistory(finished: finished)
|
self.prepareCurrentItemForHistory(finished: finished)
|
||||||
|
|
||||||
self.hide()
|
self.hide()
|
||||||
|
|
||||||
Delay.by(0.7) { [weak self] in
|
Delay.by(0.8) { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
if playingInPictureInPicture { self.closePiP() }
|
self.closePiP()
|
||||||
|
|
||||||
withAnimation {
|
withAnimation {
|
||||||
self.currentItem = nil
|
self.currentItem = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
self.updateNowPlayingInfo()
|
self.updateNowPlayingInfo()
|
||||||
|
|
||||||
self.backend.closeItem()
|
self.backend.closeItem()
|
||||||
self.aspectRatio = VideoPlayerView.defaultAspectRatio
|
self.aspectRatio = VideoPlayerView.defaultAspectRatio
|
||||||
self.resetAutoplay()
|
self.resetAutoplay()
|
||||||
self.closing = false
|
self.closing = false
|
||||||
}
|
self.playingFullScreen = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startPiP() {
|
func startPiP() {
|
||||||
previousActiveBackend = activeBackend
|
|
||||||
avPlayerBackend.startPictureInPictureOnPlay = false
|
avPlayerBackend.startPictureInPictureOnPlay = false
|
||||||
avPlayerBackend.startPictureInPictureOnSwitch = false
|
avPlayerBackend.startPictureInPictureOnSwitch = false
|
||||||
|
|
||||||
guard activeBackend != .appleAVPlayer else {
|
if activeBackend == .appleAVPlayer {
|
||||||
avPlayerBackend.tryStartingPictureInPicture()
|
avPlayerBackend.tryStartingPictureInPicture()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
avPlayerBackend.startPictureInPictureOnSwitch = true
|
guard let video = currentVideo else { return }
|
||||||
|
guard let stream = avPlayerBackend.bestPlayable(availableStreams, maxResolution: .hd720p30) else { return }
|
||||||
|
|
||||||
saveTime {
|
exitFullScreen()
|
||||||
self.changeActiveBackend(from: .mpv, to: .appleAVPlayer)
|
|
||||||
_ = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in
|
if avPlayerBackend.video == video {
|
||||||
if let pipController = self?.pipController, pipController.isPictureInPictureActive, self?.avPlayerBackend.isPlaying == true {
|
if activeBackend != .appleAVPlayer {
|
||||||
self?.exitFullScreen()
|
avPlayerBackend.startPictureInPictureOnSwitch = true
|
||||||
self?.controls.objectWillChange.send()
|
|
||||||
timer.invalidate()
|
|
||||||
} else if self?.activeBackend == .appleAVPlayer, self?.avPlayerBackend.startPictureInPictureOnSwitch == false {
|
|
||||||
self?.avPlayerBackend.startPictureInPictureOnSwitch = true
|
|
||||||
self?.avPlayerBackend.tryStartingPictureInPicture()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
changeActiveBackend(from: activeBackend, to: .appleAVPlayer)
|
||||||
|
} else {
|
||||||
|
avPlayerBackend.startPictureInPictureOnPlay = true
|
||||||
|
playStream(stream, of: video, preservingTime: true, upgrading: true, withBackend: avPlayerBackend)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
controls.objectWillChange.send()
|
||||||
}
|
}
|
||||||
|
|
||||||
var transitioningToPiP: Bool {
|
var transitioningToPiP: Bool {
|
||||||
@ -782,28 +701,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
show()
|
show()
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
avPlayerBackend.closePiP()
|
backend.closePiP()
|
||||||
_ = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in
|
|
||||||
if self?.activeBackend == .appleAVPlayer, self?.avPlayerBackend.isPlaying == true, self?.playingInPictureInPicture == false {
|
|
||||||
timer.invalidate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
guard previousActiveBackend == .mpv else { return }
|
|
||||||
|
|
||||||
saveTime {
|
|
||||||
self.changeActiveBackend(from: .appleAVPlayer, to: .mpv, isInClosePip: true)
|
|
||||||
_ = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in
|
|
||||||
if self?.activeBackend == .mpv, self?.mpvBackend.isPlaying == true {
|
|
||||||
timer.invalidate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// We need to remove the itme from the player, if not it will be displayed when next video goe to PiP.
|
|
||||||
Delay.by(1.0) {
|
|
||||||
self.avPlayerBackend.closeItem()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var pipImage: String {
|
var pipImage: String {
|
||||||
@ -815,34 +713,29 @@ final class PlayerModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func toggleFullScreenAction() {
|
func toggleFullScreenAction() {
|
||||||
toggleFullscreen(playingFullScreen, showControls: false, initiatedByButton: true)
|
toggleFullscreen(playingFullScreen, showControls: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func togglePiPAction() {
|
func togglePiPAction() {
|
||||||
if pipController?.isPictureInPictureActive ?? false {
|
(pipController?.isPictureInPictureActive ?? false) ? closePiP() : startPiP()
|
||||||
closePiP()
|
|
||||||
} else {
|
|
||||||
startPiP()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
var lockOrientationImage: String {
|
var lockOrientationImage: String {
|
||||||
isOrientationLocked ? "lock.rotation" : "lock.rotation.open"
|
lockedOrientation.isNil ? "lock.rotation.open" : "lock.rotation"
|
||||||
}
|
}
|
||||||
|
|
||||||
func lockOrientationAction() {
|
func lockOrientationAction() {
|
||||||
// This makes toggling orientation lock more robust
|
if lockedOrientation.isNil {
|
||||||
if lockedOrientation.isNil || !isOrientationLocked {
|
|
||||||
isOrientationLocked = true
|
|
||||||
let orientationMask = OrientationTracker.shared.currentInterfaceOrientationMask
|
let orientationMask = OrientationTracker.shared.currentInterfaceOrientationMask
|
||||||
lockedOrientation = orientationMask
|
lockedOrientation = orientationMask
|
||||||
let orientation = OrientationTracker.shared.currentInterfaceOrientation
|
let orientation = OrientationTracker.shared.currentInterfaceOrientation
|
||||||
Orientation.lockOrientation(orientationMask, andRotateTo: playingFullScreen ? nil : orientation)
|
Orientation.lockOrientation(orientationMask, andRotateTo: .landscapeLeft)
|
||||||
|
// iOS 16 workaround
|
||||||
|
Orientation.lockOrientation(orientationMask, andRotateTo: orientation)
|
||||||
} else {
|
} else {
|
||||||
isOrientationLocked = false
|
|
||||||
lockedOrientation = nil
|
lockedOrientation = nil
|
||||||
Orientation.lockOrientation(.all)
|
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: OrientationTracker.shared.currentInterfaceOrientation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@ -860,12 +753,10 @@ final class PlayerModel: ObservableObject {
|
|||||||
|
|
||||||
func handleCurrentItemChange() {
|
func handleCurrentItemChange() {
|
||||||
if currentItem == nil {
|
if currentItem == nil {
|
||||||
|
captions = nil
|
||||||
FeedModel.shared.calculateUnwatchedFeed()
|
FeedModel.shared.calculateUnwatchedFeed()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Captions need to be set to nil on item change, to clear the previus values.
|
|
||||||
captions = nil
|
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
Windows.player.window?.title = windowTitle
|
Windows.player.window?.title = windowTitle
|
||||||
#endif
|
#endif
|
||||||
@ -921,40 +812,37 @@ final class PlayerModel: ObservableObject {
|
|||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.playerAPI(item.video)?.loadDetails(item, failureHandler: nil) { newItem in
|
self.playerAPI(item.video)?.loadDetails(item, completionHandler: { newItem in
|
||||||
guard newItem.videoID == self.autoplayItem?.videoID else { return }
|
guard newItem.videoID == self.autoplayItem?.videoID else { return }
|
||||||
self.autoplayItem = newItem
|
self.autoplayItem = newItem
|
||||||
self.updateRemoteCommandCenter()
|
self.updateRemoteCommandCenter()
|
||||||
self.controls.objectWillChange.send()
|
self.controls.objectWillChange.send()
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateRemoteCommandCenter() {
|
func updateRemoteCommandCenter() {
|
||||||
let commandCenter = MPRemoteCommandCenter.shared()
|
let skipForwardCommand = MPRemoteCommandCenter.shared().skipForwardCommand
|
||||||
let skipForwardCommand = commandCenter.skipForwardCommand
|
let skipBackwardCommand = MPRemoteCommandCenter.shared().skipBackwardCommand
|
||||||
let skipBackwardCommand = commandCenter.skipBackwardCommand
|
let previousTrackCommand = MPRemoteCommandCenter.shared().previousTrackCommand
|
||||||
let previousTrackCommand = commandCenter.previousTrackCommand
|
let nextTrackCommand = MPRemoteCommandCenter.shared().nextTrackCommand
|
||||||
let nextTrackCommand = commandCenter.nextTrackCommand
|
|
||||||
|
|
||||||
if !remoteCommandCenterConfigured {
|
if !remoteCommandCenterConfigured {
|
||||||
remoteCommandCenterConfigured = true
|
remoteCommandCenterConfigured = true
|
||||||
|
|
||||||
|
#if !os(macOS)
|
||||||
|
try? AVAudioSession.sharedInstance().setCategory(
|
||||||
|
.playback,
|
||||||
|
mode: .moviePlayback
|
||||||
|
)
|
||||||
|
|
||||||
|
UIApplication.shared.beginReceivingRemoteControlEvents()
|
||||||
|
#endif
|
||||||
|
|
||||||
let interval = TimeInterval(systemControlsSeekDuration) ?? 10
|
let interval = TimeInterval(systemControlsSeekDuration) ?? 10
|
||||||
let preferredIntervals = [NSNumber(value: interval)]
|
let preferredIntervals = [NSNumber(value: interval)]
|
||||||
|
|
||||||
// Remove existing targets to avoid duplicates
|
|
||||||
skipForwardCommand.removeTarget(nil)
|
|
||||||
skipBackwardCommand.removeTarget(nil)
|
|
||||||
previousTrackCommand.removeTarget(nil)
|
|
||||||
nextTrackCommand.removeTarget(nil)
|
|
||||||
commandCenter.playCommand.removeTarget(nil)
|
|
||||||
commandCenter.pauseCommand.removeTarget(nil)
|
|
||||||
commandCenter.togglePlayPauseCommand.removeTarget(nil)
|
|
||||||
commandCenter.changePlaybackPositionCommand.removeTarget(nil)
|
|
||||||
|
|
||||||
// Re-add targets for handling commands
|
|
||||||
skipForwardCommand.preferredIntervals = preferredIntervals
|
skipForwardCommand.preferredIntervals = preferredIntervals
|
||||||
skipBackwardCommand.preferredIntervals = preferredIntervals
|
skipBackwardCommand.preferredIntervals = preferredIntervals
|
||||||
|
|
||||||
@ -978,22 +866,22 @@ final class PlayerModel: ObservableObject {
|
|||||||
return .success
|
return .success
|
||||||
}
|
}
|
||||||
|
|
||||||
commandCenter.playCommand.addTarget { [weak self] _ in
|
MPRemoteCommandCenter.shared().playCommand.addTarget { [weak self] _ in
|
||||||
self?.play()
|
self?.play()
|
||||||
return .success
|
return .success
|
||||||
}
|
}
|
||||||
|
|
||||||
commandCenter.pauseCommand.addTarget { [weak self] _ in
|
MPRemoteCommandCenter.shared().pauseCommand.addTarget { [weak self] _ in
|
||||||
self?.pause()
|
self?.pause()
|
||||||
return .success
|
return .success
|
||||||
}
|
}
|
||||||
|
|
||||||
commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
|
MPRemoteCommandCenter.shared().togglePlayPauseCommand.addTarget { [weak self] _ in
|
||||||
self?.togglePlay()
|
self?.togglePlay()
|
||||||
return .success
|
return .success
|
||||||
}
|
}
|
||||||
|
|
||||||
commandCenter.changePlaybackPositionCommand.addTarget { [weak self] remoteEvent in
|
MPRemoteCommandCenter.shared().changePlaybackPositionCommand.addTarget { [weak self] remoteEvent in
|
||||||
guard let event = remoteEvent as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
|
guard let event = remoteEvent as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
|
||||||
|
|
||||||
self?.backend.seek(to: event.positionTime, seekType: .userInteracted)
|
self?.backend.seek(to: event.positionTime, seekType: .userInteracted)
|
||||||
@ -1028,43 +916,22 @@ final class PlayerModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
func handleEnterForeground() {
|
func handleEnterForeground() {
|
||||||
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
|
setNeedsDrawing(presentingPlayer)
|
||||||
guard let self = self else { return }
|
avPlayerBackend.bindPlayerToLayer()
|
||||||
|
|
||||||
if !self.musicMode, self.activeBackend == .mpv {
|
|
||||||
self.mpvBackend.addVideoTrackFromStream()
|
|
||||||
self.mpvBackend.setVideoToAuto()
|
|
||||||
self.mpvBackend.controls.resetTimer()
|
|
||||||
} else if !self.musicMode, self.activeBackend == .appleAVPlayer {
|
|
||||||
self.avPlayerBackend.bindPlayerToLayer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#if os(iOS)
|
|
||||||
OrientationTracker.shared.startDeviceOrientationTracking()
|
|
||||||
#endif
|
|
||||||
|
|
||||||
guard closePiPAndOpenPlayerOnEnteringForeground, playingInPictureInPicture else {
|
guard closePiPAndOpenPlayerOnEnteringForeground, playingInPictureInPicture else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
show()
|
show()
|
||||||
// Needs to be delayed a bit, otherwise the PiP windows stays open
|
closePiP()
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
||||||
self?.closePiP()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleEnterBackground() {
|
func handleEnterBackground() {
|
||||||
#if os(iOS)
|
|
||||||
OrientationTracker.shared.stopDeviceOrientationTracking()
|
|
||||||
#endif
|
|
||||||
|
|
||||||
if Defaults[.pauseOnEnteringBackground], !playingInPictureInPicture, !musicMode {
|
if Defaults[.pauseOnEnteringBackground], !playingInPictureInPicture, !musicMode {
|
||||||
pause()
|
pause()
|
||||||
} else if !playingInPictureInPicture, activeBackend == .appleAVPlayer {
|
} else if !playingInPictureInPicture {
|
||||||
avPlayerBackend.removePlayerFromLayer()
|
avPlayerBackend.removePlayerFromLayer()
|
||||||
} else if activeBackend == .mpv, !musicMode {
|
|
||||||
mpvBackend.setVideoToNo()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@ -1074,7 +941,6 @@ final class PlayerModel: ObservableObject {
|
|||||||
|
|
||||||
logger.info("entering fullscreen")
|
logger.info("entering fullscreen")
|
||||||
toggleFullscreen(false, showControls: showControls)
|
toggleFullscreen(false, showControls: showControls)
|
||||||
self.playingFullScreen = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func exitFullScreen(showControls: Bool = true) {
|
func exitFullScreen(showControls: Bool = true) {
|
||||||
@ -1082,7 +948,6 @@ final class PlayerModel: ObservableObject {
|
|||||||
|
|
||||||
logger.info("exiting fullscreen")
|
logger.info("exiting fullscreen")
|
||||||
toggleFullscreen(true, showControls: showControls)
|
toggleFullscreen(true, showControls: showControls)
|
||||||
self.playingFullScreen = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateNowPlayingInfo() {
|
func updateNowPlayingInfo() {
|
||||||
@ -1090,22 +955,18 @@ final class PlayerModel: ObservableObject {
|
|||||||
guard activeBackend == .mpv else { return }
|
guard activeBackend == .mpv else { return }
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
if activeBackend == .appleAVPlayer, avPlayerUsesSystemControls {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
guard let video = currentItem?.video else {
|
guard let video = currentItem?.video else {
|
||||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = .none
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = .none
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentTime = (backend.currentTime?.seconds.isFinite ?? false) ? backend.currentTime!.seconds : 0
|
let currentTime = (backend.currentTime?.seconds.isFinite ?? false) ? backend.currentTime!.seconds : 0
|
||||||
|
|
||||||
// Determine the media type based on musicMode
|
|
||||||
let mediaType: NSNumber
|
|
||||||
if musicMode {
|
|
||||||
mediaType = MPMediaType.anyAudio.rawValue as NSNumber
|
|
||||||
} else {
|
|
||||||
mediaType = MPMediaType.anyVideo.rawValue as NSNumber
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare the Now Playing info dictionary
|
|
||||||
var nowPlayingInfo: [String: AnyObject] = [
|
var nowPlayingInfo: [String: AnyObject] = [
|
||||||
MPMediaItemPropertyTitle: video.displayTitle as AnyObject,
|
MPMediaItemPropertyTitle: video.displayTitle as AnyObject,
|
||||||
MPMediaItemPropertyArtist: video.displayAuthor as AnyObject,
|
MPMediaItemPropertyArtist: video.displayAuthor as AnyObject,
|
||||||
@ -1113,7 +974,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime as AnyObject,
|
MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime as AnyObject,
|
||||||
MPNowPlayingInfoPropertyPlaybackQueueCount: queue.count as AnyObject,
|
MPNowPlayingInfoPropertyPlaybackQueueCount: queue.count as AnyObject,
|
||||||
MPNowPlayingInfoPropertyPlaybackQueueIndex: 1 as AnyObject,
|
MPNowPlayingInfoPropertyPlaybackQueueIndex: 1 as AnyObject,
|
||||||
MPMediaItemPropertyMediaType: mediaType
|
MPMediaItemPropertyMediaType: MPMediaType.anyVideo.rawValue as AnyObject
|
||||||
]
|
]
|
||||||
|
|
||||||
if !currentArtwork.isNil {
|
if !currentArtwork.isNil {
|
||||||
@ -1134,7 +995,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
|
|
||||||
func updateCurrentArtwork() {
|
func updateCurrentArtwork() {
|
||||||
guard let video = currentVideo,
|
guard let video = currentVideo,
|
||||||
let thumbnailURL = video.thumbnailURL(quality: Constants.isIPhone ? .medium : .maxres)
|
let thumbnailURL = video.thumbnailURL(quality: .medium)
|
||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -1156,7 +1017,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
task.resume()
|
task.resume()
|
||||||
}
|
}
|
||||||
|
|
||||||
func toggleFullscreen(_ isFullScreen: Bool, showControls: Bool = true, initiatedByButton: Bool = false) {
|
func toggleFullscreen(_ isFullScreen: Bool, showControls: Bool = true) {
|
||||||
controls.presentingControls = showControls && isFullScreen
|
controls.presentingControls = showControls && isFullScreen
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@ -1168,27 +1029,18 @@ final class PlayerModel: ObservableObject {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
if playingFullScreen {
|
if playingFullScreen {
|
||||||
if activeBackend == .appleAVPlayer, avPlayerUsesSystemControls {
|
if activeBackend == .appleAVPlayer, avPlayerUsesSystemControls {
|
||||||
fullscreenInitiatedByButton = initiatedByButton
|
|
||||||
avPlayerBackend.controller.enterFullScreen(animated: true)
|
avPlayerBackend.controller.enterFullScreen(animated: true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let lockOrientation = rotateToLandscapeOnEnterFullScreen.interfaceOrientation
|
guard rotateToLandscapeOnEnterFullScreen.isRotating else { return }
|
||||||
if currentVideoIsLandscape {
|
if currentVideoIsLandscape {
|
||||||
if initiatedByButton {
|
let delay = activeBackend == .appleAVPlayer && avPlayerUsesSystemControls ? 0.8 : 0
|
||||||
Orientation.lockOrientation(isOrientationLocked
|
// not sure why but first rotation call is ignore so doing rotate to same orientation first
|
||||||
? (lockOrientation == .landscapeRight ? .landscapeRight : .landscapeLeft)
|
Delay.by(delay) {
|
||||||
: .landscape)
|
let orientation = OrientationTracker.shared.currentDeviceOrientation.isLandscape ? OrientationTracker.shared.currentInterfaceOrientation : self.rotateToLandscapeOnEnterFullScreen.interaceOrientation
|
||||||
|
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: OrientationTracker.shared.currentInterfaceOrientation)
|
||||||
|
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: orientation)
|
||||||
}
|
}
|
||||||
let orientation = OrientationTracker.shared.currentDeviceOrientation.isLandscape
|
|
||||||
? OrientationTracker.shared.currentInterfaceOrientation
|
|
||||||
: rotateToLandscapeOnEnterFullScreen.interfaceOrientation
|
|
||||||
|
|
||||||
Orientation.lockOrientation(
|
|
||||||
isOrientationLocked
|
|
||||||
? (lockOrientation == .landscapeRight ? .landscapeRight : .landscapeLeft)
|
|
||||||
: .all,
|
|
||||||
andRotateTo: orientation
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if activeBackend == .appleAVPlayer, avPlayerUsesSystemControls {
|
if activeBackend == .appleAVPlayer, avPlayerUsesSystemControls {
|
||||||
@ -1196,12 +1048,10 @@ final class PlayerModel: ObservableObject {
|
|||||||
avPlayerBackend.controller.dismiss(animated: true)
|
avPlayerBackend.controller.dismiss(animated: true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if lockPortraitWhenBrowsing {
|
let rotationOrientation = Constants.isIPhone ? UIInterfaceOrientation.portrait : nil
|
||||||
lockedOrientation = UIInterfaceOrientationMask.portrait
|
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: rotationOrientation)
|
||||||
}
|
|
||||||
let rotationOrientation = lockPortraitWhenBrowsing ? UIInterfaceOrientation.portrait : nil
|
|
||||||
Orientation.lockOrientation(lockPortraitWhenBrowsing ? .portrait : .all, andRotateTo: rotationOrientation)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1258,213 +1108,4 @@ final class PlayerModel: ObservableObject {
|
|||||||
onPlayStream.forEach { $0(stream) }
|
onPlayStream.forEach { $0(stream) }
|
||||||
onPlayStream.removeAll()
|
onPlayStream.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateTime(_ cmTime: CMTime) {
|
|
||||||
let time = CMTimeGetSeconds(cmTime)
|
|
||||||
let newChapterIndex = chapterForTime(time)
|
|
||||||
if currentChapterIndex != newChapterIndex {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.currentChapterIndex = newChapterIndex
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func chapterForTime(_ time: Double) -> Int? {
|
|
||||||
guard let chapters = self.videoForDisplay?.chapters else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for (index, chapter) in chapters.enumerated() {
|
|
||||||
let nextChapterStartTime = index < (chapters.count - 1) ? chapters[index + 1].start : nil
|
|
||||||
|
|
||||||
if let nextChapterStart = nextChapterStartTime {
|
|
||||||
if time >= chapter.start, time < nextChapterStart {
|
|
||||||
return index
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if time >= chapter.start {
|
|
||||||
return index
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
#if !os(macOS)
|
|
||||||
func setAudioSessionActive(_ setActive: Bool) {
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
|
||||||
do {
|
|
||||||
try AVAudioSession.sharedInstance().setActive(setActive)
|
|
||||||
} catch {
|
|
||||||
self.logger.error("Error setting up audio session: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func handleAudioSessionInterruption(_ notification: Notification) {
|
|
||||||
logger.info("Audio session interruption received.")
|
|
||||||
logger.info("Notification object: \(String(describing: notification.object))")
|
|
||||||
|
|
||||||
guard let info = notification.userInfo else {
|
|
||||||
logger.info("userInfo is missing in the notification.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract the interruption type
|
|
||||||
guard let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
|
||||||
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
|
|
||||||
else {
|
|
||||||
logger.info("AVAudioSessionInterruptionTypeKey is missing or not a UInt in userInfo.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("Interruption type received: \(type)")
|
|
||||||
|
|
||||||
// Check availability for iOS 14.5 or newer to handle interruption reason
|
|
||||||
// Currently only for debugging purpose
|
|
||||||
#if os(iOS)
|
|
||||||
if #available(iOS 14.5, *) {
|
|
||||||
// Extract the interruption reason, if available
|
|
||||||
if let reasonValue = info[AVAudioSessionInterruptionReasonKey] as? UInt,
|
|
||||||
let reason = AVAudioSession.InterruptionReason(rawValue: reasonValue)
|
|
||||||
{
|
|
||||||
logger.info("Interruption reason received: \(reason)")
|
|
||||||
switch reason {
|
|
||||||
case .default:
|
|
||||||
logger.info("Interruption reason: Default or unspecified interruption occurred.")
|
|
||||||
case .appWasSuspended:
|
|
||||||
logger.info("Interruption reason: The app was suspended during the interruption.")
|
|
||||||
@unknown default:
|
|
||||||
logger.info("Unknown interruption reason received.")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.info("AVAudioSessionInterruptionReasonKey is missing or not a UInt in userInfo.")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.info("Interruption reason handling is not available on this iOS version.")
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Handle the specific interruption type
|
|
||||||
switch type {
|
|
||||||
case .began:
|
|
||||||
pause()
|
|
||||||
logger.info("Audio session interrupted (began).")
|
|
||||||
case .ended:
|
|
||||||
// Extract any interruption options, if available
|
|
||||||
if let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt {
|
|
||||||
logger.info("Interruption options received: \(optionsValue)")
|
|
||||||
if optionsValue & AVAudioSession.InterruptionOptions.shouldResume.rawValue != 0 {
|
|
||||||
play()
|
|
||||||
logger.info("Interruption option indicates playback should resume automatically.")
|
|
||||||
} else {
|
|
||||||
logger.info("Interruption option indicates playback should not resume automatically.")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.info("AVAudioSessionInterruptionOptionKey is missing or not a UInt in userInfo.")
|
|
||||||
}
|
|
||||||
logger.info("Audio session interruption ended.")
|
|
||||||
// Check if audio was resumed or if there's any indication of ducking
|
|
||||||
let currentVolume = AVAudioSession.sharedInstance().outputVolume
|
|
||||||
logger.info("Current output volume: \(currentVolume)")
|
|
||||||
default:
|
|
||||||
logger.info("Unknown interruption type received.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func handleRouteChange(_ notification: Notification) {
|
|
||||||
logger.info("Audio route change received.")
|
|
||||||
|
|
||||||
guard let info = notification.userInfo else {
|
|
||||||
logger.info("userInfo is missing in the notification.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
|
||||||
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue)
|
|
||||||
else {
|
|
||||||
logger.info("AVAudioSessionRouteChangeReasonKey is missing or not a UInt in userInfo.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("Route change reason received: \(reason)")
|
|
||||||
|
|
||||||
let currentCategory = AVAudioSession.sharedInstance().category
|
|
||||||
logger.info("Current audio session category before change: \(currentCategory)")
|
|
||||||
|
|
||||||
switch reason {
|
|
||||||
case .categoryChange:
|
|
||||||
logger.info("Audio session category changed.")
|
|
||||||
let newCategory = AVAudioSession.sharedInstance().category
|
|
||||||
logger.info("New audio session category: \(newCategory)")
|
|
||||||
case .oldDeviceUnavailable, .newDeviceAvailable:
|
|
||||||
logger.info("Audio route change may indicate ducking or device change.")
|
|
||||||
let currentRoute = AVAudioSession.sharedInstance().currentRoute
|
|
||||||
logger.info("Current audio route: \(currentRoute)")
|
|
||||||
|
|
||||||
for output in currentRoute.outputs {
|
|
||||||
logger.info("Output port type: \(output.portType), UID: \(output.uid)")
|
|
||||||
switch output.portType {
|
|
||||||
case .headphones, .bluetoothA2DP:
|
|
||||||
logger.info("Detected port type \(output.portType). Executing play().")
|
|
||||||
play()
|
|
||||||
default:
|
|
||||||
logger.info("Detected port type \(output.portType). Executing pause().")
|
|
||||||
pause()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case .noSuitableRouteForCategory:
|
|
||||||
logger.info("No suitable route for the current category.")
|
|
||||||
default:
|
|
||||||
logger.info("Unhandled route change reason: \(reason)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if os(macOS)
|
|
||||||
private func assignKeyPressMonitor() {
|
|
||||||
keyPressMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] keyEvent -> NSEvent? in
|
|
||||||
// Check if the player window is the key window
|
|
||||||
guard let self, let window = Windows.playerWindow, window.isKeyWindow else { return keyEvent }
|
|
||||||
|
|
||||||
switch keyEvent.keyCode {
|
|
||||||
case 124:
|
|
||||||
if !self.liveStreamInAVPlayer {
|
|
||||||
let interval = TimeInterval(self.buttonForwardSeekDuration) ?? 10
|
|
||||||
self.backend.seek(
|
|
||||||
relative: .secondsInDefaultTimescale(interval),
|
|
||||||
seekType: .userInteracted
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case 123:
|
|
||||||
if !self.liveStreamInAVPlayer {
|
|
||||||
let interval = TimeInterval(self.buttonBackwardSeekDuration) ?? 10
|
|
||||||
self.backend.seek(
|
|
||||||
relative: .secondsInDefaultTimescale(-interval),
|
|
||||||
seekType: .userInteracted
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case 3:
|
|
||||||
self.toggleFullscreen(
|
|
||||||
self.playingFullScreen,
|
|
||||||
showControls: false
|
|
||||||
)
|
|
||||||
case 49:
|
|
||||||
if !self.controls.isLoadingVideo {
|
|
||||||
self.backend.togglePlay()
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return keyEvent
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func destroyKeyPressMonitor() {
|
|
||||||
if let keyPressMonitor {
|
|
||||||
NSEvent.removeMonitor(keyPressMonitor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
@ -94,9 +94,7 @@ extension PlayerModel {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.videoBeingOpened = nil
|
self.videoBeingOpened = nil
|
||||||
self.streamsWithInstance(instance: playerInstance, streams: video.streams) { processedStreams in
|
self.availableStreams = self.streamsWithInstance(instance: playerInstance, streams: video.streams)
|
||||||
self.availableStreams = processedStreams
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -107,7 +105,6 @@ extension PlayerModel {
|
|||||||
|
|
||||||
func playerAPI(_ video: Video) -> VideosAPI? {
|
func playerAPI(_ video: Video) -> VideosAPI? {
|
||||||
guard let url = video.instanceURL else { return accounts.api }
|
guard let url = video.instanceURL else { return accounts.api }
|
||||||
if accounts.current?.url == url { return accounts.api }
|
|
||||||
switch video.app {
|
switch video.app {
|
||||||
case .local:
|
case .local:
|
||||||
return nil
|
return nil
|
||||||
@ -127,32 +124,14 @@ extension PlayerModel {
|
|||||||
var streamByQualityProfile: Stream? {
|
var streamByQualityProfile: Stream? {
|
||||||
let profile = qualityProfile ?? .defaultProfile
|
let profile = qualityProfile ?? .defaultProfile
|
||||||
|
|
||||||
// First attempt: Filter by both `canPlay` and `isPreferred`
|
|
||||||
if let streamPreferredForProfile = backend.bestPlayable(
|
if let streamPreferredForProfile = backend.bestPlayable(
|
||||||
availableStreams.filter { backend.canPlay($0) && profile.isPreferred($0) },
|
availableStreams.filter { backend.canPlay($0) && profile.isPreferred($0) },
|
||||||
maxResolution: profile.resolution, formatOrder: profile.formats
|
maxResolution: profile.resolution
|
||||||
) {
|
) {
|
||||||
return streamPreferredForProfile
|
return streamPreferredForProfile
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: Filter by `canPlay` only
|
return backend.bestPlayable(availableStreams.filter { backend.canPlay($0) }, maxResolution: profile.resolution)
|
||||||
let fallbackStream = backend.bestPlayable(
|
|
||||||
availableStreams.filter { backend.canPlay($0) },
|
|
||||||
maxResolution: profile.resolution, formatOrder: profile.formats
|
|
||||||
)
|
|
||||||
|
|
||||||
// If no stream is found, trigger the error handler
|
|
||||||
guard let finalStream = fallbackStream else {
|
|
||||||
let error = RequestError(
|
|
||||||
userMessage: "No supported streams available.",
|
|
||||||
cause: NSError(domain: "stream.yatte.app", code: -1, userInfo: [NSLocalizedDescriptionKey: "No supported streams available"])
|
|
||||||
)
|
|
||||||
videoLoadFailureHandler(error, video: currentVideo)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the found stream
|
|
||||||
return finalStream
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func advanceToNextItem() {
|
func advanceToNextItem() {
|
||||||
@ -349,41 +328,16 @@ extension PlayerModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
playerAPI(video)?
|
playerAPI(video)?
|
||||||
.loadDetails(item, failureHandler: nil) { [weak self] newItem in
|
.loadDetails(item, completionHandler: { [weak self] newItem in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
|
||||||
replaceQueueItem(newItem)
|
replaceQueueItem(newItem)
|
||||||
|
|
||||||
self.logger.info("LOADED queue details: \(videoID)")
|
self.logger.info("LOADED queue details: \(videoID)")
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private func videoLoadFailureHandler(_ error: RequestError, video: Video? = nil) {
|
private func videoLoadFailureHandler(_ error: RequestError, video: Video? = nil) {
|
||||||
guard let video else {
|
|
||||||
presentErrorAlert(error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let videoID = video.videoID
|
|
||||||
let currentRetry = retryAttempts[videoID] ?? 0
|
|
||||||
|
|
||||||
if currentRetry < Defaults[.videoLoadingRetryCount] {
|
|
||||||
retryAttempts[videoID] = currentRetry + 1
|
|
||||||
|
|
||||||
logger.info("Retry attempt \(currentRetry + 1) for video \(videoID) due to error: \(error)")
|
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
self.enqueueVideo(video, play: true, prepending: true, loadDetails: true)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
retryAttempts[videoID] = 0
|
|
||||||
presentErrorAlert(error, video: video)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func presentErrorAlert(_ error: RequestError, video: Video? = nil) {
|
|
||||||
var message = error.userMessage
|
var message = error.userMessage
|
||||||
if let errorDictionary = error.json.dictionaryObject,
|
if let errorDictionary = error.json.dictionaryObject,
|
||||||
let errorMessage = errorDictionary["message"] ?? errorDictionary["error"],
|
let errorMessage = errorDictionary["message"] ?? errorDictionary["error"],
|
||||||
|
@ -44,6 +44,22 @@ extension PlayerModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func skip(_ segment: Segment, at time: CMTime) {
|
private func skip(_ segment: Segment, at time: CMTime) {
|
||||||
|
if let duration = playerItemDuration, segment.endTime.seconds >= duration.seconds - 3 {
|
||||||
|
logger.error("segment end time is: \(segment.end) when player item duration is: \(duration.seconds)")
|
||||||
|
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.pause()
|
||||||
|
|
||||||
|
self.backend.eofPlaybackModeAction()
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
backend.seek(to: segment.endTime, seekType: .segmentSkip(segment.category))
|
backend.seek(to: segment.endTime, seekType: .segmentSkip(segment.category))
|
||||||
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
@ -53,14 +69,6 @@ extension PlayerModel {
|
|||||||
self?.segmentRestorationTime = time
|
self?.segmentRestorationTime = time
|
||||||
}
|
}
|
||||||
logger.info("SponsorBlock skipping to: \(segment.end)")
|
logger.info("SponsorBlock skipping to: \(segment.end)")
|
||||||
|
|
||||||
if let duration = playerItemDuration, segment.endTime.seconds >= duration.seconds - 3 {
|
|
||||||
logger.error("Segment end time is: \(segment.end) when player item duration is: \(duration.seconds)")
|
|
||||||
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
self?.backend.eofPlaybackModeAction()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func shouldSkip(_ segment: Segment, at time: CMTime) -> Bool {
|
private func shouldSkip(_ segment: Segment, at time: CMTime) -> Bool {
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import AVFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Siesta
|
import Siesta
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@ -42,9 +41,7 @@ extension PlayerModel {
|
|||||||
self.logger.info("ignoring loaded streams from \(instance.description) as current video has changed")
|
self.logger.info("ignoring loaded streams from \(instance.description) as current video has changed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.streamsWithInstance(instance: instance, streams: video.streams) { processedStreams in
|
self.availableStreams = self.streamsWithInstance(instance: instance, streams: video.streams)
|
||||||
self.availableStreams = processedStreams
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
self.logger.critical("no streams available from \(instance.description)")
|
self.logger.critical("no streams available from \(instance.description)")
|
||||||
}
|
}
|
||||||
@ -56,172 +53,28 @@ extension PlayerModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func streamsWithInstance(instance: Instance, streams: [Stream], completion: @escaping ([Stream]) -> Void) {
|
func streamsWithInstance(instance: Instance, streams: [Stream]) -> [Stream] {
|
||||||
// Queue for stream processing
|
streams.map { stream in
|
||||||
let streamProcessingQueue = DispatchQueue(label: "stream.yattee.streamProcessing.Queue")
|
stream.instance = instance
|
||||||
// Queue for accessing the processedStreams array
|
|
||||||
let processedStreamsQueue = DispatchQueue(label: "stream.yattee.processedStreams.Queue")
|
|
||||||
// DispatchGroup for managing multiple tasks
|
|
||||||
let streamProcessingGroup = DispatchGroup()
|
|
||||||
|
|
||||||
var processedStreams = [Stream]()
|
if instance.app == .invidious, instance.proxiesVideos {
|
||||||
let instance = instance
|
|
||||||
|
|
||||||
var hasForbiddenAsset = false
|
|
||||||
var hasAllowedAsset = false
|
|
||||||
|
|
||||||
for stream in streams {
|
|
||||||
streamProcessingQueue.async(group: streamProcessingGroup) {
|
|
||||||
let forbiddenAssetTestGroup = DispatchGroup()
|
|
||||||
if !hasAllowedAsset, !hasForbiddenAsset, !instance.proxiesVideos, stream.format != Stream.Format.unknown {
|
|
||||||
let (nonHLSAssets, hlsURLs) = self.getAssets(from: [stream])
|
|
||||||
if let firstStream = nonHLSAssets.first {
|
|
||||||
let asset = firstStream.0
|
|
||||||
let url = firstStream.1
|
|
||||||
let requestRange = firstStream.2
|
|
||||||
|
|
||||||
if instance.app == .invidious {
|
|
||||||
self.testAsset(url: url, range: requestRange, isHLS: false, forbiddenAssetTestGroup: forbiddenAssetTestGroup) { status in
|
|
||||||
switch status {
|
|
||||||
case HTTPStatus.Forbidden:
|
|
||||||
hasForbiddenAsset = true
|
|
||||||
case HTTPStatus.PartialContent:
|
|
||||||
hasAllowedAsset = true
|
|
||||||
case HTTPStatus.OK:
|
|
||||||
hasAllowedAsset = true
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if instance.app == .piped {
|
|
||||||
self.testPipedAssets(asset: asset!, requestRange: requestRange, isHLS: false, forbiddenAssetTestGroup: forbiddenAssetTestGroup) { status in
|
|
||||||
switch status {
|
|
||||||
case HTTPStatus.Forbidden:
|
|
||||||
hasForbiddenAsset = true
|
|
||||||
case HTTPStatus.PartialContent:
|
|
||||||
hasAllowedAsset = true
|
|
||||||
case HTTPStatus.OK:
|
|
||||||
hasAllowedAsset = true
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if let firstHLS = hlsURLs.first {
|
|
||||||
let asset = AVURLAsset(url: firstHLS)
|
|
||||||
if instance.app == .piped {
|
|
||||||
self.testPipedAssets(asset: asset, requestRange: nil, isHLS: true, forbiddenAssetTestGroup: forbiddenAssetTestGroup) { status in
|
|
||||||
switch status {
|
|
||||||
case HTTPStatus.Forbidden:
|
|
||||||
hasForbiddenAsset = true
|
|
||||||
case HTTPStatus.PartialContent:
|
|
||||||
hasAllowedAsset = true
|
|
||||||
case HTTPStatus.OK:
|
|
||||||
hasAllowedAsset = true
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
forbiddenAssetTestGroup.wait()
|
|
||||||
|
|
||||||
// Post-processing code
|
|
||||||
if instance.app == .invidious, hasForbiddenAsset || instance.proxiesVideos {
|
|
||||||
if let audio = stream.audioAsset {
|
if let audio = stream.audioAsset {
|
||||||
stream.audioAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: audio)
|
stream.audioAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: audio)
|
||||||
}
|
}
|
||||||
if let video = stream.videoAsset {
|
if let video = stream.videoAsset {
|
||||||
stream.videoAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: video)
|
stream.videoAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: video)
|
||||||
}
|
}
|
||||||
} else if instance.app == .piped, !instance.proxiesVideos, !hasForbiddenAsset {
|
|
||||||
if let hlsURL = stream.hlsURL {
|
|
||||||
PipedAPI.nonProxiedAsset(url: hlsURL) { possibleNonProxiedURL in
|
|
||||||
if let nonProxiedURL = possibleNonProxiedURL {
|
|
||||||
stream.hlsURL = nonProxiedURL.url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if let audio = stream.audioAsset {
|
|
||||||
PipedAPI.nonProxiedAsset(asset: audio) { nonProxiedAudioAsset in
|
|
||||||
stream.audioAsset = nonProxiedAudioAsset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let video = stream.videoAsset {
|
|
||||||
PipedAPI.nonProxiedAsset(asset: video) { nonProxiedVideoAsset in
|
|
||||||
stream.videoAsset = nonProxiedVideoAsset
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return stream
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append to processedStreams within the processedStreamsQueue
|
func streamsSorter(_ lhs: Stream, _ rhs: Stream) -> Bool {
|
||||||
processedStreamsQueue.sync {
|
if lhs.resolution.isNil || rhs.resolution.isNil {
|
||||||
processedStreams.append(stream)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
streamProcessingGroup.notify(queue: .main) {
|
|
||||||
// Access and pass processedStreams within the processedStreamsQueue block
|
|
||||||
processedStreamsQueue.sync {
|
|
||||||
completion(processedStreams)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func getAssets(from streams: [Stream]) -> (nonHLSAssets: [(AVURLAsset?, URL, String?)], hlsURLs: [URL]) {
|
|
||||||
var nonHLSAssets = [(AVURLAsset?, URL, String?)]()
|
|
||||||
var hlsURLs = [URL]()
|
|
||||||
|
|
||||||
for stream in streams {
|
|
||||||
if stream.isHLS {
|
|
||||||
if let url = stream.hlsURL?.url {
|
|
||||||
hlsURLs.append(url)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if let asset = stream.audioAsset {
|
|
||||||
nonHLSAssets.append((asset, asset.url, stream.requestRange))
|
|
||||||
}
|
|
||||||
if let asset = stream.videoAsset {
|
|
||||||
nonHLSAssets.append((asset, asset.url, stream.requestRange))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (nonHLSAssets, hlsURLs)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func testAsset(url: URL, range: String?, isHLS: Bool, forbiddenAssetTestGroup: DispatchGroup, completion: @escaping (Int) -> Void) {
|
|
||||||
// In case the range is nil, generate a random one.
|
|
||||||
let randomEnd = Int.random(in: 200 ... 800)
|
|
||||||
let requestRange = range ?? "0-\(randomEnd)"
|
|
||||||
|
|
||||||
forbiddenAssetTestGroup.enter()
|
|
||||||
URLTester.testURLResponse(url: url, range: requestRange, isHLS: isHLS) { statusCode in
|
|
||||||
completion(statusCode)
|
|
||||||
forbiddenAssetTestGroup.leave()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func testPipedAssets(asset: AVURLAsset, requestRange: String?, isHLS: Bool, forbiddenAssetTestGroup: DispatchGroup, completion: @escaping (Int) -> Void) {
|
|
||||||
PipedAPI.nonProxiedAsset(asset: asset) { possibleNonProxiedAsset in
|
|
||||||
if let nonProxiedAsset = possibleNonProxiedAsset {
|
|
||||||
self.testAsset(url: nonProxiedAsset.url, range: requestRange, isHLS: isHLS, forbiddenAssetTestGroup: forbiddenAssetTestGroup, completion: completion)
|
|
||||||
} else {
|
|
||||||
completion(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func streamsSorter(lhs: Stream, rhs: Stream) -> Bool {
|
|
||||||
// Use optional chaining to simplify nil handling
|
|
||||||
guard let lhsRes = lhs.resolution?.height, let rhsRes = rhs.resolution?.height else {
|
|
||||||
return lhs.kind < rhs.kind
|
return lhs.kind < rhs.kind
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compare either kind or resolution based on conditions
|
return lhs.kind == rhs.kind ? (lhs.resolution.height > rhs.resolution.height) : (lhs.kind < rhs.kind)
|
||||||
return lhs.kind == rhs.kind ? (lhsRes > rhsRes) : (lhs.kind < rhs.kind)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,12 +16,10 @@ struct ScreenSaverManager {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
noSleepReturn = IOPMAssertionCreateWithName(
|
noSleepReturn = IOPMAssertionCreateWithName(kIOPMAssertionTypeNoDisplaySleep as CFString,
|
||||||
kIOPMAssertionTypeNoDisplaySleep as CFString,
|
|
||||||
IOPMAssertionLevel(kIOPMAssertionLevelOn),
|
IOPMAssertionLevel(kIOPMAssertionLevelOn),
|
||||||
reason as CFString,
|
reason as CFString,
|
||||||
&noSleepAssertion
|
&noSleepAssertion)
|
||||||
)
|
|
||||||
return noSleepReturn == kIOReturnSuccess
|
return noSleepReturn == kIOReturnSuccess
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -60,7 +60,7 @@ struct Playlist: Identifiable, Equatable, Hashable {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
static func == (lhs: Playlist, rhs: Playlist) -> Bool {
|
||||||
lhs.id == rhs.id && lhs.updated == rhs.updated
|
lhs.id == rhs.id && lhs.updated == rhs.updated
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -69,10 +69,7 @@ final class PlaylistsModel: ObservableObject {
|
|||||||
.onSuccess { resource in
|
.onSuccess { resource in
|
||||||
self.error = nil
|
self.error = nil
|
||||||
if let playlists: [Playlist] = resource.typedContent() {
|
if let playlists: [Playlist] = resource.typedContent() {
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
self.playlists = playlists
|
self.playlists = playlists
|
||||||
}
|
|
||||||
PlaylistsCacheModel.shared.storePlaylist(account: account, playlists: playlists)
|
PlaylistsCacheModel.shared.storePlaylist(account: account, playlists: playlists)
|
||||||
onSuccess()
|
onSuccess()
|
||||||
}
|
}
|
||||||
|
@ -3,15 +3,15 @@ import Foundation
|
|||||||
|
|
||||||
struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
|
struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
|
||||||
static var bridge = QualityProfileBridge()
|
static var bridge = QualityProfileBridge()
|
||||||
static var defaultProfile = Self(id: "default", backend: .mpv, resolution: .hd720p60, formats: [.stream], order: Array(Format.allCases.indices))
|
static var defaultProfile = Self(id: "default", backend: .mpv, resolution: .hd720p60, formats: [.stream])
|
||||||
|
|
||||||
enum Format: String, CaseIterable, Identifiable, Defaults.Serializable {
|
enum Format: String, CaseIterable, Identifiable, Defaults.Serializable {
|
||||||
case avc1
|
|
||||||
case stream
|
|
||||||
case webm
|
|
||||||
case mp4
|
|
||||||
case av1
|
|
||||||
case hls
|
case hls
|
||||||
|
case stream
|
||||||
|
case mp4
|
||||||
|
case avc1
|
||||||
|
case av1
|
||||||
|
case webm
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
rawValue
|
rawValue
|
||||||
@ -23,6 +23,7 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
|
|||||||
return "Stream"
|
return "Stream"
|
||||||
case .webm:
|
case .webm:
|
||||||
return "WebM"
|
return "WebM"
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return rawValue.uppercased()
|
return rawValue.uppercased()
|
||||||
}
|
}
|
||||||
@ -30,18 +31,18 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
|
|||||||
|
|
||||||
var streamFormat: Stream.Format? {
|
var streamFormat: Stream.Format? {
|
||||||
switch self {
|
switch self {
|
||||||
case .avc1:
|
|
||||||
return .avc1
|
|
||||||
case .stream:
|
|
||||||
return nil
|
|
||||||
case .webm:
|
|
||||||
return .webm
|
|
||||||
case .mp4:
|
|
||||||
return .mp4
|
|
||||||
case .av1:
|
|
||||||
return .av1
|
|
||||||
case .hls:
|
case .hls:
|
||||||
return nil
|
return nil
|
||||||
|
case .stream:
|
||||||
|
return nil
|
||||||
|
case .mp4:
|
||||||
|
return .mp4
|
||||||
|
case .webm:
|
||||||
|
return .webm
|
||||||
|
case .avc1:
|
||||||
|
return .avc1
|
||||||
|
case .av1:
|
||||||
|
return .av1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -52,23 +53,20 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
|
|||||||
var backend: PlayerBackendType
|
var backend: PlayerBackendType
|
||||||
var resolution: ResolutionSetting
|
var resolution: ResolutionSetting
|
||||||
var formats: [Format]
|
var formats: [Format]
|
||||||
var order: [Int]
|
|
||||||
var description: String {
|
var description: String {
|
||||||
if let name, !name.isEmpty { return name }
|
if let name, !name.isEmpty { return name }
|
||||||
return "\(backend.label) - \(resolution.description) - \(formatsDescription)"
|
return "\(backend.label) - \(resolution.description) - \(formatsDescription)"
|
||||||
}
|
}
|
||||||
|
|
||||||
var formatsDescription: String {
|
var formatsDescription: String {
|
||||||
switch formats.count {
|
if formats.count == Format.allCases.count {
|
||||||
case Format.allCases.count:
|
|
||||||
return "Any format".localized()
|
return "Any format".localized()
|
||||||
case 0:
|
} else if formats.count <= 3 {
|
||||||
return "No format selected".localized()
|
|
||||||
case 1 ... 3:
|
|
||||||
return formats.map(\.description).joined(separator: ", ")
|
return formats.map(\.description).joined(separator: ", ")
|
||||||
default:
|
|
||||||
return String(format: "%@ formats".localized(), String(formats.count))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return String(format: "%@ formats".localized(), String(formats.count))
|
||||||
}
|
}
|
||||||
|
|
||||||
func isPreferred(_ stream: Stream) -> Bool {
|
func isPreferred(_ stream: Stream) -> Bool {
|
||||||
@ -76,8 +74,7 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
let defaultResolution = Stream.Resolution.custom(height: 720, refreshRate: 30)
|
let resolutionMatch = !stream.resolution.isNil && resolution.value >= stream.resolution
|
||||||
let resolutionMatch = resolution.value ?? defaultResolution >= stream.resolution
|
|
||||||
|
|
||||||
if resolutionMatch, formats.contains(.stream), stream.kind == .stream {
|
if resolutionMatch, formats.contains(.stream), stream.kind == .stream {
|
||||||
return true
|
return true
|
||||||
@ -103,8 +100,7 @@ struct QualityProfileBridge: Defaults.Bridge {
|
|||||||
"name": value.name ?? "",
|
"name": value.name ?? "",
|
||||||
"backend": value.backend.rawValue,
|
"backend": value.backend.rawValue,
|
||||||
"resolution": value.resolution.rawValue,
|
"resolution": value.resolution.rawValue,
|
||||||
"formats": value.formats.map(\.rawValue).joined(separator: Self.formatsSeparator),
|
"formats": value.formats.map { $0.rawValue }.joined(separator: Self.formatsSeparator)
|
||||||
"order": value.order.map { String($0) }.joined(separator: Self.formatsSeparator) // New line
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -119,8 +115,7 @@ struct QualityProfileBridge: Defaults.Bridge {
|
|||||||
|
|
||||||
let name = object["name"]
|
let name = object["name"]
|
||||||
let formats = (object["formats"] ?? "").components(separatedBy: Self.formatsSeparator).compactMap { QualityProfile.Format(rawValue: $0) }
|
let formats = (object["formats"] ?? "").components(separatedBy: Self.formatsSeparator).compactMap { QualityProfile.Format(rawValue: $0) }
|
||||||
let order = (object["order"] ?? "").components(separatedBy: Self.formatsSeparator).compactMap { Int($0) }
|
|
||||||
|
|
||||||
return .init(id: id, name: name, backend: backend, resolution: resolution, formats: formats, order: order)
|
return .init(id: id, name: name, backend: backend, resolution: resolution, formats: formats)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -62,12 +62,6 @@ final class RecentsModel: ObservableObject {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var presentedItem: RecentItem? {
|
|
||||||
guard let recent = items.last else { return nil }
|
|
||||||
|
|
||||||
return recent
|
|
||||||
}
|
|
||||||
|
|
||||||
static func symbolSystemImage(_ name: String) -> String {
|
static func symbolSystemImage(_ name: String) -> String {
|
||||||
let firstLetter = name.first?.lowercased()
|
let firstLetter = name.first?.lowercased()
|
||||||
let regex = #"^[a-z0-9]$"#
|
let regex = #"^[a-z0-9]$"#
|
||||||
|
@ -18,14 +18,6 @@ final class SearchModel: ObservableObject {
|
|||||||
|
|
||||||
@Published var focused = false
|
@Published var focused = false
|
||||||
|
|
||||||
@Default(.showSearchSuggestions) private var showSearchSuggestions
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
var textField: UITextField!
|
|
||||||
#elseif os(macOS)
|
|
||||||
var textField: NSTextField!
|
|
||||||
#endif
|
|
||||||
|
|
||||||
var accounts: AccountsModel { .shared }
|
var accounts: AccountsModel { .shared }
|
||||||
private var resource: Resource!
|
private var resource: Resource!
|
||||||
|
|
||||||
@ -104,7 +96,7 @@ final class SearchModel: ObservableObject {
|
|||||||
}}
|
}}
|
||||||
|
|
||||||
func loadSuggestions(_ query: String) {
|
func loadSuggestions(_ query: String) {
|
||||||
guard accounts.app.supportsSearchSuggestions, showSearchSuggestions else {
|
guard accounts.app.supportsSearchSuggestions else {
|
||||||
querySuggestions.removeAll()
|
querySuggestions.removeAll()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -71,13 +71,13 @@ final class SeekModel: ObservableObject {
|
|||||||
func showOSD() {
|
func showOSD() {
|
||||||
guard !presentingOSD else { return }
|
guard !presentingOSD else { return }
|
||||||
|
|
||||||
presentingOSD = true
|
withAnimation(.easeIn(duration: 0.1)) { self.presentingOSD = true }
|
||||||
}
|
}
|
||||||
|
|
||||||
func hideOSD() {
|
func hideOSD() {
|
||||||
guard presentingOSD else { return }
|
guard presentingOSD else { return }
|
||||||
|
|
||||||
presentingOSD = false
|
withAnimation(.easeIn(duration: 0.1)) { self.presentingOSD = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
func hideOSDWithDelay() {
|
func hideOSDWithDelay() {
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum SeekType: Equatable {
|
enum SeekType: Equatable {
|
||||||
case chapterSkip(String)
|
|
||||||
case segmentSkip(String)
|
case segmentSkip(String)
|
||||||
case segmentRestore
|
case segmentRestore
|
||||||
case userInteracted
|
case userInteracted
|
||||||
|
@ -7,9 +7,6 @@ final class SettingsModel: ObservableObject {
|
|||||||
@Published var presentingAlert = false
|
@Published var presentingAlert = false
|
||||||
@Published var alert = Alert(title: Text("Error"))
|
@Published var alert = Alert(title: Text("Error"))
|
||||||
|
|
||||||
@Published var presentingSettingsImportSheet = false
|
|
||||||
@Published var settingsImportURL: URL?
|
|
||||||
|
|
||||||
func presentAlert(title: String, message: String? = nil) {
|
func presentAlert(title: String, message: String? = nil) {
|
||||||
let message = message.isNil ? nil : Text(message!)
|
let message = message.isNil ? nil : Text(message!)
|
||||||
alert = Alert(title: Text(title), message: message)
|
alert = Alert(title: Text(title), message: message)
|
||||||
@ -20,9 +17,4 @@ final class SettingsModel: ObservableObject {
|
|||||||
self.alert = alert
|
self.alert = alert
|
||||||
presentingAlert = true
|
presentingAlert = true
|
||||||
}
|
}
|
||||||
|
|
||||||
func presentSettingsImportSheet(_ url: URL) {
|
|
||||||
settingsImportURL = url
|
|
||||||
presentingSettingsImportSheet = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,7 @@ import Logging
|
|||||||
import SwiftyJSON
|
import SwiftyJSON
|
||||||
|
|
||||||
final class SponsorBlockAPI: ObservableObject {
|
final class SponsorBlockAPI: ObservableObject {
|
||||||
static let categories = ["sponsor", "selfpromo", "interaction", "intro", "outro", "preview", "filler", "music_offtopic"]
|
static let categories = ["sponsor", "selfpromo", "intro", "outro", "interaction", "music_offtopic"]
|
||||||
|
|
||||||
let logger = Logger(label: "stream.yattee.app.sb")
|
let logger = Logger(label: "stream.yattee.app.sb")
|
||||||
|
|
||||||
@ -13,7 +13,7 @@ final class SponsorBlockAPI: ObservableObject {
|
|||||||
@Published var segments = [Segment]()
|
@Published var segments = [Segment]()
|
||||||
|
|
||||||
static func categoryDescription(_ name: String) -> String? {
|
static func categoryDescription(_ name: String) -> String? {
|
||||||
guard categories.contains(name) else {
|
guard Self.categories.contains(name) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -21,26 +21,22 @@ final class SponsorBlockAPI: ObservableObject {
|
|||||||
case "sponsor":
|
case "sponsor":
|
||||||
return "Sponsor".localized()
|
return "Sponsor".localized()
|
||||||
case "selfpromo":
|
case "selfpromo":
|
||||||
return "Unpaid/Self Promotion".localized()
|
return "Self-promotion".localized()
|
||||||
case "interaction":
|
|
||||||
return "Interaction Reminder (Subscribe)".localized()
|
|
||||||
case "intro":
|
case "intro":
|
||||||
return "Intermission/Intro Animation".localized()
|
return "Intro".localized()
|
||||||
case "outro":
|
case "outro":
|
||||||
return "Endcards/Credits".localized()
|
return "Outro".localized()
|
||||||
case "preview":
|
case "interaction":
|
||||||
return "Preview/Recap/Hook".localized()
|
return "Interaction".localized()
|
||||||
case "filler":
|
|
||||||
return "Filler Tangent/Jokes".localized()
|
|
||||||
case "music_offtopic":
|
case "music_offtopic":
|
||||||
return "Music: Non-Music Section".localized()
|
return "Offtopic in Music Videos".localized()
|
||||||
default:
|
default:
|
||||||
return name.capitalized
|
return name.capitalized
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func categoryDetails(_ name: String) -> String? {
|
static func categoryDetails(_ name: String) -> String? {
|
||||||
guard categories.contains(name) else {
|
guard Self.categories.contains(name) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,14 +46,9 @@ final class SponsorBlockAPI: ObservableObject {
|
|||||||
"The creator will receive payment or compensation in the form of money or free products.").localized()
|
"The creator will receive payment or compensation in the form of money or free products.").localized()
|
||||||
|
|
||||||
case "selfpromo":
|
case "selfpromo":
|
||||||
return ("The creator will not receive any payment in exchange for this promotion. " +
|
return ("Promoting a product or service that is directly related to the creator themselves. " +
|
||||||
"This includes charity drives or free shout outs for products or other people they like.\n\n" +
|
|
||||||
"Promoting a product or service that is directly related to the creator themselves. " +
|
|
||||||
"This usually includes merchandise or promotion of monetized platforms.").localized()
|
"This usually includes merchandise or promotion of monetized platforms.").localized()
|
||||||
|
|
||||||
case "interaction":
|
|
||||||
return "Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video).".localized()
|
|
||||||
|
|
||||||
case "intro":
|
case "intro":
|
||||||
return ("Segments typically found at the start of a video that include an animation, " +
|
return ("Segments typically found at the start of a video that include an animation, " +
|
||||||
"still frame or clip which are also seen in other videos by the same creator.").localized()
|
"still frame or clip which are also seen in other videos by the same creator.").localized()
|
||||||
@ -65,11 +56,8 @@ final class SponsorBlockAPI: ObservableObject {
|
|||||||
case "outro":
|
case "outro":
|
||||||
return "Typically near or at the end of the video when the credits pop up and/or endcards are shown.".localized()
|
return "Typically near or at the end of the video when the credits pop up and/or endcards are shown.".localized()
|
||||||
|
|
||||||
case "preview":
|
case "interaction":
|
||||||
return "Collection of clips that show what is coming up in in this video or other videos in a series where all information is repeated later in the video".localized()
|
return "Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video).".localized()
|
||||||
|
|
||||||
case "filler":
|
|
||||||
return "Filler Tangent/ Jokes is only for tangential scenes added only for filler or humor that are not required to understand the main content of the video.".localized()
|
|
||||||
|
|
||||||
case "music_offtopic":
|
case "music_offtopic":
|
||||||
return "For videos which feature music as the primary content.".localized()
|
return "For videos which feature music as the primary content.".localized()
|
||||||
@ -112,8 +100,8 @@ final class SponsorBlockAPI: ObservableObject {
|
|||||||
self.segments = JSON(value).arrayValue.map(SponsorBlockSegment.init).sorted { $0.end < $1.end }
|
self.segments = JSON(value).arrayValue.map(SponsorBlockSegment.init).sorted { $0.end < $1.end }
|
||||||
|
|
||||||
self.logger.info("loaded \(self.segments.count) SponsorBlock segments")
|
self.logger.info("loaded \(self.segments.count) SponsorBlock segments")
|
||||||
for segment in self.segments {
|
self.segments.forEach {
|
||||||
self.logger.info("\(segment.start) -> \(segment.end)")
|
self.logger.info("\($0.start) -> \($0.end)")
|
||||||
}
|
}
|
||||||
case let .failure(error):
|
case let .failure(error):
|
||||||
self.segments = []
|
self.segments = []
|
||||||
|
@ -4,7 +4,7 @@ import Siesta
|
|||||||
final class Store<Data>: ResourceObserver, ObservableObject {
|
final class Store<Data>: ResourceObserver, ObservableObject {
|
||||||
@Published private var all: Data?
|
@Published private var all: Data?
|
||||||
|
|
||||||
var collection: Data { all ?? ([item].compactMap { $0 } as! Data) }
|
var collection: Data { all ?? ([] as! Data) }
|
||||||
var item: Data? { all }
|
var item: Data? { all }
|
||||||
|
|
||||||
init(_ data: Data? = nil) {
|
init(_ data: Data? = nil) {
|
||||||
|
@ -4,130 +4,67 @@ import Foundation
|
|||||||
|
|
||||||
// swiftlint:disable:next final_class
|
// swiftlint:disable:next final_class
|
||||||
class Stream: Equatable, Hashable, Identifiable {
|
class Stream: Equatable, Hashable, Identifiable {
|
||||||
enum Resolution: Comparable, Codable, Defaults.Serializable {
|
enum Resolution: String, CaseIterable, Comparable, Defaults.Serializable {
|
||||||
case predefined(PredefinedResolution)
|
case hd2160p60
|
||||||
case custom(height: Int, refreshRate: Int)
|
case hd2160p50
|
||||||
|
case hd2160p48
|
||||||
enum PredefinedResolution: String, CaseIterable, Codable {
|
case hd2160p30
|
||||||
// 8K UHD (16:9) Resolutions
|
case hd1440p60
|
||||||
case hd4320p60, hd4320p30
|
case hd1440p50
|
||||||
|
case hd1440p48
|
||||||
// 4K UHD (16:9) Resolutions
|
case hd1440p30
|
||||||
case hd2160p60, hd2160p30
|
case hd1080p60
|
||||||
|
case hd1080p50
|
||||||
// 1440p (16:9) Resolutions
|
case hd1080p48
|
||||||
case hd1440p60, hd1440p30
|
case hd1080p30
|
||||||
|
case hd720p60
|
||||||
// 1080p (Full HD, 16:9) Resolutions
|
case hd720p50
|
||||||
case hd1080p60, hd1080p30
|
case hd720p48
|
||||||
|
case hd720p30
|
||||||
// 720p (HD, 16:9) Resolutions
|
|
||||||
case hd720p60, hd720p30
|
|
||||||
|
|
||||||
// Standard Definition (SD) Resolutions
|
|
||||||
case sd480p30
|
case sd480p30
|
||||||
case sd360p30
|
case sd360p30
|
||||||
case sd240p30
|
case sd240p30
|
||||||
case sd144p30
|
case sd144p30
|
||||||
}
|
case unknown
|
||||||
|
|
||||||
var name: String {
|
var name: String {
|
||||||
switch self {
|
"\(height)p\(refreshRate != -1 && refreshRate != 30 ? ", \(refreshRate) fps" : "")"
|
||||||
case let .predefined(predefined):
|
|
||||||
return predefined.rawValue
|
|
||||||
case let .custom(height, refreshRate):
|
|
||||||
return "\(height)p\(refreshRate != 30 ? ", \(refreshRate) fps" : "")"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var height: Int {
|
var height: Int {
|
||||||
switch self {
|
if self == .unknown {
|
||||||
case let .predefined(predefined):
|
return -1
|
||||||
return predefined.height
|
|
||||||
case let .custom(height, _):
|
|
||||||
return height
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let resolutionPart = rawValue.components(separatedBy: "p").first!
|
||||||
|
return Int(resolutionPart.components(separatedBy: CharacterSet.decimalDigits.inverted).joined())!
|
||||||
}
|
}
|
||||||
|
|
||||||
var refreshRate: Int {
|
var refreshRate: Int {
|
||||||
switch self {
|
if self == .unknown {
|
||||||
case let .predefined(predefined):
|
return -1
|
||||||
return predefined.refreshRate
|
|
||||||
case let .custom(_, refreshRate):
|
|
||||||
return refreshRate
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var bitrate: Int {
|
let refreshRatePart = rawValue.components(separatedBy: "p")[1]
|
||||||
switch self {
|
|
||||||
case let .predefined(predefined):
|
if refreshRatePart.isEmpty {
|
||||||
return predefined.bitrate
|
return 30
|
||||||
case let .custom(height, refreshRate):
|
|
||||||
// Find the closest predefined resolution based on height and refresh rate
|
|
||||||
let closestPredefined = Stream.Resolution.PredefinedResolution.allCases.min {
|
|
||||||
abs($0.height - height) + abs($0.refreshRate - refreshRate) <
|
|
||||||
abs($1.height - height) + abs($1.refreshRate - refreshRate)
|
|
||||||
}
|
|
||||||
// Return the bitrate of the closest predefined resolution or a default bitrate if no close match is found
|
|
||||||
return closestPredefined?.bitrate ?? 5_000_000
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func from(resolution: String, fps: Int? = nil) -> Self {
|
return Int(refreshRatePart.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? -1
|
||||||
if let predefined = PredefinedResolution(rawValue: resolution) {
|
|
||||||
return .predefined(predefined)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt to parse height and refresh rate
|
static func from(resolution: String, fps: Int? = nil) -> Resolution {
|
||||||
if let height = Int(resolution.components(separatedBy: "p").first ?? ""), height > 0 {
|
allCases.first { $0.rawValue.contains(resolution) && $0.refreshRate == (fps ?? 30) } ?? .unknown
|
||||||
let refreshRate = fps ?? 30
|
|
||||||
return .custom(height: height, refreshRate: refreshRate)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default behavior if parsing fails
|
static func < (lhs: Resolution, rhs: Resolution) -> Bool {
|
||||||
return .custom(height: 720, refreshRate: 30)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func < (lhs: Self, rhs: Self) -> Bool {
|
|
||||||
lhs.height == rhs.height ? (lhs.refreshRate < rhs.refreshRate) : (lhs.height < rhs.height)
|
lhs.height == rhs.height ? (lhs.refreshRate < rhs.refreshRate) : (lhs.height < rhs.height)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case predefined
|
|
||||||
case custom
|
|
||||||
case height
|
|
||||||
case refreshRate
|
|
||||||
}
|
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
|
|
||||||
if let predefinedValue = try? container.decode(PredefinedResolution.self, forKey: .predefined) {
|
|
||||||
self = .predefined(predefinedValue)
|
|
||||||
} else if let height = try? container.decode(Int.self, forKey: .height),
|
|
||||||
let refreshRate = try? container.decode(Int.self, forKey: .refreshRate)
|
|
||||||
{
|
|
||||||
self = .custom(height: height, refreshRate: refreshRate)
|
|
||||||
} else {
|
|
||||||
// Set default resolution to 720p 30 if decoding fails
|
|
||||||
self = .custom(height: 720, refreshRate: 30)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func encode(to encoder: Encoder) throws {
|
|
||||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
||||||
switch self {
|
|
||||||
case let .predefined(predefinedValue):
|
|
||||||
try container.encode(predefinedValue, forKey: .predefined)
|
|
||||||
case let .custom(height, refreshRate):
|
|
||||||
try container.encode(height, forKey: .height)
|
|
||||||
try container.encode(refreshRate, forKey: .refreshRate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Kind: String, Comparable {
|
enum Kind: String, Comparable {
|
||||||
case hls, adaptive, stream
|
case stream, adaptive, hls
|
||||||
|
|
||||||
private var sortOrder: Int {
|
private var sortOrder: Int {
|
||||||
switch self {
|
switch self {
|
||||||
@ -140,28 +77,42 @@ class Stream: Equatable, Hashable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func < (lhs: Self, rhs: Self) -> Bool {
|
static func < (lhs: Kind, rhs: Kind) -> Bool {
|
||||||
lhs.sortOrder < rhs.sortOrder
|
lhs.sortOrder < rhs.sortOrder
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Format: String {
|
enum Format: String, Comparable {
|
||||||
case avc1
|
|
||||||
case mp4
|
|
||||||
case av1
|
|
||||||
case webm
|
case webm
|
||||||
case hls
|
case avc1
|
||||||
case stream
|
case av1
|
||||||
|
case mp4
|
||||||
case unknown
|
case unknown
|
||||||
|
|
||||||
|
private var sortOrder: Int {
|
||||||
|
switch self {
|
||||||
|
case .mp4:
|
||||||
|
return 0
|
||||||
|
case .avc1:
|
||||||
|
return 1
|
||||||
|
case .av1:
|
||||||
|
return 2
|
||||||
|
case .webm:
|
||||||
|
return 3
|
||||||
|
case .unknown:
|
||||||
|
return 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func < (lhs: Self, rhs: Self) -> Bool {
|
||||||
|
lhs.sortOrder < rhs.sortOrder
|
||||||
|
}
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .webm:
|
case .webm:
|
||||||
return "WebM"
|
return "WebM"
|
||||||
case .hls:
|
|
||||||
return "adaptive (HLS)"
|
|
||||||
case .stream:
|
|
||||||
return "Stream"
|
|
||||||
default:
|
default:
|
||||||
return rawValue.uppercased()
|
return rawValue.uppercased()
|
||||||
}
|
}
|
||||||
@ -170,27 +121,19 @@ class Stream: Equatable, Hashable, Identifiable {
|
|||||||
static func from(_ string: String) -> Self {
|
static func from(_ string: String) -> Self {
|
||||||
let lowercased = string.lowercased()
|
let lowercased = string.lowercased()
|
||||||
|
|
||||||
if lowercased.contains("avc1") {
|
|
||||||
return .avc1
|
|
||||||
}
|
|
||||||
if lowercased.contains("mpeg_4") || lowercased.contains("mp4") {
|
|
||||||
return .mp4
|
|
||||||
}
|
|
||||||
if lowercased.contains("av01") {
|
|
||||||
return .av1
|
|
||||||
}
|
|
||||||
if lowercased.contains("webm") {
|
if lowercased.contains("webm") {
|
||||||
return .webm
|
return .webm
|
||||||
}
|
} else if lowercased.contains("avc1") {
|
||||||
if lowercased.contains("stream") {
|
return .avc1
|
||||||
return .stream
|
} else if lowercased.contains("av01") {
|
||||||
}
|
return .av1
|
||||||
if lowercased.contains("hls") {
|
} else if lowercased.contains("mpeg_4") || lowercased.contains("mp4") {
|
||||||
return .hls
|
return .mp4
|
||||||
}
|
} else {
|
||||||
return .unknown
|
return .unknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
|
|
||||||
@ -206,8 +149,6 @@ class Stream: Equatable, Hashable, Identifiable {
|
|||||||
|
|
||||||
var encoding: String?
|
var encoding: String?
|
||||||
var videoFormat: String?
|
var videoFormat: String?
|
||||||
var bitrate: Int?
|
|
||||||
var requestRange: String?
|
|
||||||
|
|
||||||
init(
|
init(
|
||||||
instance: Instance? = nil,
|
instance: Instance? = nil,
|
||||||
@ -218,9 +159,7 @@ class Stream: Equatable, Hashable, Identifiable {
|
|||||||
resolution: Resolution? = nil,
|
resolution: Resolution? = nil,
|
||||||
kind: Kind = .hls,
|
kind: Kind = .hls,
|
||||||
encoding: String? = nil,
|
encoding: String? = nil,
|
||||||
videoFormat: String? = nil,
|
videoFormat: String? = nil
|
||||||
bitrate: Int? = nil,
|
|
||||||
requestRange: String? = nil
|
|
||||||
) {
|
) {
|
||||||
self.instance = instance
|
self.instance = instance
|
||||||
self.audioAsset = audioAsset
|
self.audioAsset = audioAsset
|
||||||
@ -231,8 +170,6 @@ class Stream: Equatable, Hashable, Identifiable {
|
|||||||
self.kind = kind
|
self.kind = kind
|
||||||
self.encoding = encoding
|
self.encoding = encoding
|
||||||
format = .from(videoFormat ?? "")
|
format = .from(videoFormat ?? "")
|
||||||
self.bitrate = bitrate
|
|
||||||
self.requestRange = requestRange
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var isLocal: Bool {
|
var isLocal: Bool {
|
||||||
@ -245,31 +182,23 @@ class Stream: Equatable, Hashable, Identifiable {
|
|||||||
|
|
||||||
var quality: String {
|
var quality: String {
|
||||||
guard localURL.isNil else { return "Opened File" }
|
guard localURL.isNil else { return "Opened File" }
|
||||||
|
return kind == .hls ? "adaptive (HLS)" : "\(resolution.name)\(kind == .stream ? " (\(kind.rawValue))" : "")"
|
||||||
if kind == .hls {
|
|
||||||
return "adaptive (HLS)"
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolution.name
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var shortQuality: String {
|
var shortQuality: String {
|
||||||
guard localURL.isNil else { return "File" }
|
guard localURL.isNil else { return "File" }
|
||||||
|
|
||||||
if kind == .hls {
|
if kind == .hls {
|
||||||
return "adaptive (HLS)"
|
return "HLS"
|
||||||
|
} else {
|
||||||
|
return resolution?.name ?? "?"
|
||||||
}
|
}
|
||||||
|
|
||||||
if kind == .stream {
|
|
||||||
return resolution.name
|
|
||||||
}
|
|
||||||
return resolutionAndFormat
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
guard localURL.isNil else { return resolutionAndFormat }
|
guard localURL.isNil else { return resolutionAndFormat }
|
||||||
let instanceString = instance.isNil ? "" : " - (\(instance!.description))"
|
let instanceString = instance.isNil ? "" : " - (\(instance!.description))"
|
||||||
return format != .hls ? "\(resolutionAndFormat)\(instanceString)" : "adaptive (HLS)\(instanceString)"
|
return "\(resolutionAndFormat)\(instanceString)"
|
||||||
}
|
}
|
||||||
|
|
||||||
var resolutionAndFormat: String {
|
var resolutionAndFormat: String {
|
||||||
@ -292,8 +221,7 @@ class Stream: Equatable, Hashable, Identifiable {
|
|||||||
|
|
||||||
if kind == .hls {
|
if kind == .hls {
|
||||||
return hlsURL
|
return hlsURL
|
||||||
}
|
} else if videoAssetContainsAudio {
|
||||||
if videoAssetContainsAudio {
|
|
||||||
return videoAsset.url
|
return videoAsset.url
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -316,97 +244,3 @@ class Stream: Equatable, Hashable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Stream.Resolution.PredefinedResolution {
|
|
||||||
var height: Int {
|
|
||||||
switch self {
|
|
||||||
// 8K UHD (16:9) Resolutions
|
|
||||||
case .hd4320p60, .hd4320p30:
|
|
||||||
return 4320
|
|
||||||
|
|
||||||
// 4K UHD (16:9) Resolutions
|
|
||||||
case .hd2160p60, .hd2160p30:
|
|
||||||
return 2160
|
|
||||||
|
|
||||||
// 1440p (16:9) Resolutions
|
|
||||||
case .hd1440p60, .hd1440p30:
|
|
||||||
return 1440
|
|
||||||
|
|
||||||
// 1080p (Full HD, 16:9) Resolutions
|
|
||||||
case .hd1080p60, .hd1080p30:
|
|
||||||
return 1080
|
|
||||||
|
|
||||||
// 720p (HD, 16:9) Resolutions
|
|
||||||
case .hd720p60, .hd720p30:
|
|
||||||
return 720
|
|
||||||
|
|
||||||
// Standard Definition (SD) Resolutions
|
|
||||||
case .sd480p30:
|
|
||||||
return 480
|
|
||||||
|
|
||||||
case .sd360p30:
|
|
||||||
return 360
|
|
||||||
|
|
||||||
case .sd240p30:
|
|
||||||
return 240
|
|
||||||
|
|
||||||
case .sd144p30:
|
|
||||||
return 144
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var refreshRate: Int {
|
|
||||||
switch self {
|
|
||||||
// 60 fps Resolutions
|
|
||||||
case .hd4320p60, .hd2160p60, .hd1440p60, .hd1080p60, .hd720p60:
|
|
||||||
return 60
|
|
||||||
|
|
||||||
// 30 fps Resolutions
|
|
||||||
case .hd4320p30, .hd2160p30, .hd1440p30, .hd1080p30, .hd720p30,
|
|
||||||
.sd480p30, .sd360p30, .sd240p30, .sd144p30:
|
|
||||||
return 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// These values are an approximation.
|
|
||||||
// https://support.google.com/youtube/answer/1722171?hl=en#zippy=%2Cbitrate
|
|
||||||
|
|
||||||
var bitrate: Int {
|
|
||||||
switch self {
|
|
||||||
// 8K UHD (16:9) Resolutions
|
|
||||||
case .hd4320p60:
|
|
||||||
return 180_000_000 // Midpoint between 120 Mbps and 240 Mbps
|
|
||||||
case .hd4320p30:
|
|
||||||
return 120_000_000 // Midpoint between 80 Mbps and 160 Mbps
|
|
||||||
// 4K UHD (16:9) Resolutions
|
|
||||||
case .hd2160p60:
|
|
||||||
return 60_500_000 // Midpoint between 53 Mbps and 68 Mbps
|
|
||||||
case .hd2160p30:
|
|
||||||
return 40_000_000 // Midpoint between 35 Mbps and 45 Mbps
|
|
||||||
// 1440p (2K) Resolutions
|
|
||||||
case .hd1440p60:
|
|
||||||
return 24_000_000 // 24 Mbps
|
|
||||||
case .hd1440p30:
|
|
||||||
return 16_000_000 // 16 Mbps
|
|
||||||
// 1080p (Full HD, 16:9) Resolutions
|
|
||||||
case .hd1080p60:
|
|
||||||
return 12_000_000 // 12 Mbps
|
|
||||||
case .hd1080p30:
|
|
||||||
return 8_000_000 // 8 Mbps
|
|
||||||
// 720p (HD, 16:9) Resolutions
|
|
||||||
case .hd720p60:
|
|
||||||
return 7_500_000 // 7.5 Mbps
|
|
||||||
case .hd720p30:
|
|
||||||
return 5_000_000 // 5 Mbps
|
|
||||||
// Standard Definition (SD) Resolutions
|
|
||||||
case .sd480p30:
|
|
||||||
return 2_500_000 // 2.5 Mbps
|
|
||||||
case .sd360p30:
|
|
||||||
return 1_000_000 // 1 Mbps
|
|
||||||
case .sd240p30:
|
|
||||||
return 1_000_000 // 1 Mbps
|
|
||||||
case .sd144p30:
|
|
||||||
return 600_000 // 0.6 Mbps
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -5,27 +5,10 @@ final class ThumbnailsModel: ObservableObject {
|
|||||||
static var shared = ThumbnailsModel()
|
static var shared = ThumbnailsModel()
|
||||||
|
|
||||||
@Published var unloadable = Set<URL>()
|
@Published var unloadable = Set<URL>()
|
||||||
private var retryCounts = [URL: Int]()
|
|
||||||
private let maxRetries = 3
|
|
||||||
private let retryDelay: TimeInterval = 1.0
|
|
||||||
|
|
||||||
func insertUnloadable(_ url: URL) {
|
func insertUnloadable(_ url: URL) {
|
||||||
let retries = (retryCounts[url] ?? 0) + 1
|
|
||||||
|
|
||||||
if retries >= maxRetries {
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.unloadable.insert(url)
|
self.unloadable.insert(url)
|
||||||
self.retryCounts.removeValue(forKey: url)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.retryCounts[url] = retries
|
|
||||||
}
|
|
||||||
DispatchQueue.global().asyncAfter(deadline: .now() + retryDelay) {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.retryCounts[url] = retries
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -37,23 +20,21 @@ final class ThumbnailsModel: ObservableObject {
|
|||||||
return unloadable.contains(url)
|
return unloadable.contains(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
func best(_ video: Video) -> (url: URL?, quality: Thumbnail.Quality?) {
|
func best(_ video: Video) -> URL? {
|
||||||
for quality in availableQualitites {
|
for quality in availableQualitites {
|
||||||
let url = video.thumbnailURL(quality: quality)
|
let url = video.thumbnailURL(quality: quality)
|
||||||
if !isUnloadable(url) {
|
if !isUnloadable(url) {
|
||||||
return (url, quality)
|
return url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (nil, nil)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private var availableQualitites: [Thumbnail.Quality] {
|
private var availableQualitites: [Thumbnail.Quality] {
|
||||||
switch Defaults[.thumbnailsQuality] {
|
switch Defaults[.thumbnailsQuality] {
|
||||||
case .highest:
|
case .highest:
|
||||||
return [.maxres, .high, .medium, .default]
|
return [.maxresdefault, .medium, .default]
|
||||||
case .high:
|
|
||||||
return [.high, .medium, .default]
|
|
||||||
case .medium:
|
case .medium:
|
||||||
return [.medium, .default]
|
return [.medium, .default]
|
||||||
case .low:
|
case .low:
|
||||||
|
@ -45,7 +45,7 @@ struct URLBookmarkModel {
|
|||||||
func saveBookmark(_ url: NSURL) {
|
func saveBookmark(_ url: NSURL) {
|
||||||
guard url.isFileURL else {
|
guard url.isFileURL else {
|
||||||
logger.error("trying to save bookmark for something that is not a file")
|
logger.error("trying to save bookmark for something that is not a file")
|
||||||
logger.error("not a file: \(url.absoluteString ?? "unknown")")
|
logger.error("not a file: \(url.absoluteString)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,7 +114,7 @@ struct URLBookmarkModel {
|
|||||||
func refreshAll() {
|
func refreshAll() {
|
||||||
logger.info("refreshing all bookmarks")
|
logger.info("refreshing all bookmarks")
|
||||||
|
|
||||||
for url in allURLs {
|
allURLs.forEach { url in
|
||||||
if loadBookmark(url) != nil {
|
if loadBookmark(url) != nil {
|
||||||
logger.info("bookmark for \(url) exists")
|
logger.info("bookmark for \(url) exists")
|
||||||
} else {
|
} else {
|
||||||
|
@ -53,7 +53,7 @@ struct Video: Identifiable, Equatable, Hashable {
|
|||||||
|
|
||||||
var channel: Channel
|
var channel: Channel
|
||||||
|
|
||||||
var related = [Self]()
|
var related = [Video]()
|
||||||
var chapters = [Chapter]()
|
var chapters = [Chapter]()
|
||||||
|
|
||||||
var captions = [Captions]()
|
var captions = [Captions]()
|
||||||
@ -83,7 +83,7 @@ struct Video: Identifiable, Equatable, Hashable {
|
|||||||
dislikes: Int? = nil,
|
dislikes: Int? = nil,
|
||||||
keywords: [String] = [],
|
keywords: [String] = [],
|
||||||
streams: [Stream] = [],
|
streams: [Stream] = [],
|
||||||
related: [Self] = [],
|
related: [Video] = [],
|
||||||
chapters: [Chapter] = [],
|
chapters: [Chapter] = [],
|
||||||
captions: [Captions] = []
|
captions: [Captions] = []
|
||||||
) {
|
) {
|
||||||
@ -116,7 +116,7 @@ struct Video: Identifiable, Equatable, Hashable {
|
|||||||
self.captions = captions
|
self.captions = captions
|
||||||
}
|
}
|
||||||
|
|
||||||
static func local(_ url: URL) -> Self {
|
static func local(_ url: URL) -> Video {
|
||||||
Self(
|
Self(
|
||||||
app: .local,
|
app: .local,
|
||||||
videoID: url.absoluteString,
|
videoID: url.absoluteString,
|
||||||
@ -249,7 +249,7 @@ struct Video: Identifiable, Equatable, Hashable {
|
|||||||
thumbnails.first { $0.quality == quality }?.url
|
thumbnails.first { $0.quality == quality }?.url
|
||||||
}
|
}
|
||||||
|
|
||||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
static func == (lhs: Video, rhs: Video) -> Bool {
|
||||||
let videoIDIsEqual = lhs.videoID == rhs.videoID
|
let videoIDIsEqual = lhs.videoID == rhs.videoID
|
||||||
|
|
||||||
if !lhs.indexID.isNil, !rhs.indexID.isNil {
|
if !lhs.indexID.isNil, !rhs.indexID.isNil {
|
||||||
@ -290,8 +290,8 @@ struct Video: Identifiable, Equatable, Hashable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var localStreamIsFile: Bool {
|
var localStreamIsFile: Bool {
|
||||||
guard let url = localStream?.localURL else { return false }
|
guard let localStream else { return false }
|
||||||
return url.isFileURL
|
return localStream.localURL.isFileURL
|
||||||
}
|
}
|
||||||
|
|
||||||
var localStreamIsRemoteURL: Bool {
|
var localStreamIsRemoteURL: Bool {
|
||||||
|
@ -39,7 +39,6 @@ final class URLParserTests: XCTestCase {
|
|||||||
"https://www.youtube.com/playlist?list=PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU": "PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU",
|
"https://www.youtube.com/playlist?list=PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU": "PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU",
|
||||||
"https://www.youtube.com/watch?v=playlist&list=PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU": "PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU",
|
"https://www.youtube.com/watch?v=playlist&list=PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU": "PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU",
|
||||||
"youtube.com/watch?v=playlist&list=PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU": "PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU",
|
"youtube.com/watch?v=playlist&list=PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU": "PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU",
|
||||||
"https://www.youtube.com/watch?v=ZyhrYis509A&list=PL7DA3D097D6FDBC02": "PL7DA3D097D6FDBC02",
|
|
||||||
"/watch?v=playlist&list=PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU": "PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU",
|
"/watch?v=playlist&list=PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU": "PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU",
|
||||||
"watch?v=playlist&list=PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU": "PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU",
|
"watch?v=playlist&list=PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU": "PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU",
|
||||||
"playlist?list=ABCDE": "ABCDE"
|
"playlist?list=ABCDE": "ABCDE"
|
||||||
@ -57,7 +56,7 @@ final class URLParserTests: XCTestCase {
|
|||||||
]
|
]
|
||||||
|
|
||||||
func testUrlsParsing() throws {
|
func testUrlsParsing() throws {
|
||||||
for urlString in Self.urls {
|
Self.urls.forEach { urlString in
|
||||||
let url = URL(string: urlString)!
|
let url = URL(string: urlString)!
|
||||||
let parser = URLParser(url: url)
|
let parser = URLParser(url: url)
|
||||||
XCTAssertEqual(parser.destination, .fileURL)
|
XCTAssertEqual(parser.destination, .fileURL)
|
||||||
@ -66,7 +65,7 @@ final class URLParserTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testVideosParsing() throws {
|
func testVideosParsing() throws {
|
||||||
for (url, id) in Self.videos {
|
Self.videos.forEach { url, id in
|
||||||
let parser = URLParser(url: URL(string: url)!)
|
let parser = URLParser(url: URL(string: url)!)
|
||||||
XCTAssertEqual(parser.destination, .video)
|
XCTAssertEqual(parser.destination, .video)
|
||||||
XCTAssertEqual(parser.videoID, id)
|
XCTAssertEqual(parser.videoID, id)
|
||||||
@ -74,7 +73,7 @@ final class URLParserTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testChannelsByNameParsing() throws {
|
func testChannelsByNameParsing() throws {
|
||||||
for (url, name) in Self.channelsByName {
|
Self.channelsByName.forEach { url, name in
|
||||||
let parser = URLParser(url: URL(string: url)!)
|
let parser = URLParser(url: URL(string: url)!)
|
||||||
XCTAssertEqual(parser.destination, .channel)
|
XCTAssertEqual(parser.destination, .channel)
|
||||||
XCTAssertEqual(parser.channelName, name)
|
XCTAssertEqual(parser.channelName, name)
|
||||||
@ -83,7 +82,7 @@ final class URLParserTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testChannelsByIdParsing() throws {
|
func testChannelsByIdParsing() throws {
|
||||||
for (url, id) in Self.channelsByID {
|
Self.channelsByID.forEach { url, id in
|
||||||
let parser = URLParser(url: URL(string: url)!)
|
let parser = URLParser(url: URL(string: url)!)
|
||||||
XCTAssertEqual(parser.destination, .channel)
|
XCTAssertEqual(parser.destination, .channel)
|
||||||
XCTAssertEqual(parser.channelID, id)
|
XCTAssertEqual(parser.channelID, id)
|
||||||
@ -92,7 +91,7 @@ final class URLParserTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testUsersParsing() throws {
|
func testUsersParsing() throws {
|
||||||
for (url, user) in Self.users {
|
Self.users.forEach { url, user in
|
||||||
let parser = URLParser(url: URL(string: url)!)
|
let parser = URLParser(url: URL(string: url)!)
|
||||||
XCTAssertEqual(parser.destination, .channel)
|
XCTAssertEqual(parser.destination, .channel)
|
||||||
XCTAssertNil(parser.channelID)
|
XCTAssertNil(parser.channelID)
|
||||||
@ -102,7 +101,7 @@ final class URLParserTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testPlaylistsParsing() throws {
|
func testPlaylistsParsing() throws {
|
||||||
for (url, id) in Self.playlists {
|
Self.playlists.forEach { url, id in
|
||||||
let parser = URLParser(url: URL(string: url)!)
|
let parser = URLParser(url: URL(string: url)!)
|
||||||
XCTAssertEqual(parser.destination, .playlist)
|
XCTAssertEqual(parser.destination, .playlist)
|
||||||
XCTAssertEqual(parser.playlistID, id)
|
XCTAssertEqual(parser.playlistID, id)
|
||||||
@ -110,7 +109,7 @@ final class URLParserTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testSearchesParsing() throws {
|
func testSearchesParsing() throws {
|
||||||
for (url, query) in Self.searches {
|
Self.searches.forEach { url, query in
|
||||||
let parser = URLParser(url: URL(string: url)!)
|
let parser = URLParser(url: URL(string: url)!)
|
||||||
XCTAssertEqual(parser.destination, .search)
|
XCTAssertEqual(parser.destination, .search)
|
||||||
XCTAssertEqual(parser.searchQuery, query)
|
XCTAssertEqual(parser.searchQuery, query)
|
||||||
@ -127,7 +126,7 @@ final class URLParserTests: XCTestCase {
|
|||||||
"watch?v=IUTGFQpKaPU&t=30s": 30
|
"watch?v=IUTGFQpKaPU&t=30s": 30
|
||||||
]
|
]
|
||||||
|
|
||||||
for (url, time) in samples {
|
samples.forEach { url, time in
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
URLParser(url: URL(string: url)!).time,
|
URLParser(url: URL(string: url)!).time,
|
||||||
time
|
time
|
||||||
|
@ -1,17 +1,15 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename" : "Invidious_512x512@1x.png",
|
"filename" : "Invidious.svg",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "Invidious_512x512@2x.png",
|
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "2x"
|
"scale" : "2x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "Invidious_512x512@3x.png",
|
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "3x"
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
|
2
Shared/Assets.xcassets/Invidious.imageset/Invidious.svg
vendored
Normal file
2
Shared/Assets.xcassets/Invidious.imageset/Invidious.svg
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="512pt" height="512pt" version="1.0" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><g><rect x="-.0072516" y=".00056299" width="512.01" height="512.02" fill="#575757" stroke-width=".063019"/><path d="m247.17 455.95c-19.792-0.78921-38.719-4.2564-57.154-10.47-60.968-20.55-108.68-68.579-127-127.86-7.8955-25.538-10.062-53.943-6.2586-82.067 3.7105-27.439 13.603-53.515 29.342-77.344 12.069-18.273 29.138-36.277 47.228-49.816 36.891-27.61 85.944-42.49 132.38-40.157 25.88 1.3001 49.939 6.765 73.106 16.606 8.1948 3.481 20.024 9.6845 27.696 14.525 14.15 8.9272 22.367 15.498 34.482 27.573 13.254 13.211 22.128 24.276 30.398 37.906 7.2081 11.879 14.099 27.15 18.229 40.397 1.5996 5.1305 4.442 16.456 5.6852 22.653 2.3908 11.917 2.6998 15.722 2.7049 33.312 6e-3 18.515-0.46256 24.413-2.9166 36.758-9.3274 46.92-35.58 88.167-74.872 117.64-22.814 17.112-50.027 29.535-78.547 35.858-16.714 3.7059-35.421 5.2453-54.498 4.4846zm-35.1-78.786c-5.3e-4 -0.52647-0.0741-2.0564-0.16311-3.3999l-0.16178-2.4427-4.7018-0.26271c-4.0477-0.22614-4.7968-0.33363-5.3847-0.77253-2.0235-1.5108-1.4679-6.0695 2.2494-18.457 0.8637-2.8781 3.3371-11.321 5.4966-18.762 2.1594-7.4409 5.2002-17.836 6.7573-23.101 1.5571-5.2648 4.1948-14.282 5.8615-20.038 1.6667-5.7562 3.6145-12.4 4.3284-14.764 0.71391-2.3641 3.2583-11.037 5.6542-19.272 4.9475-17.007 8.1626-27.723 8.9438-29.811 0.51852-1.3858 0.54785-1.4139 0.99761-0.95317 0.25486 0.26106 3.8462 7.3667 7.9807 15.79 4.1345 8.4236 13.089 26.573 19.898 40.331 17.188 34.73 37.849 76.578 43.261 87.622l4.5356 9.257 11.359-0.0895c6.2475-0.0492 11.615-0.19623 11.929-0.32672 0.5614-0.23385 0.54167-0.2959-1.3723-4.3176-1.068-2.2442-8.1436-16.601-15.724-31.904-48.687-98.293-61.22-123.86-67.889-138.48-4.7022-10.309-6.9031-14.807-7.7139-15.762-0.82931-0.97742-1.6319-1.0638-2.3704-0.25525-1.1993 1.313-4.1046 10.063-9.3869 28.27-2.0569 7.0899-6.5372 22.425-9.9562 34.077-6.6396 22.629-8.5182 29.037-14.33 48.883-2.0354 6.9495-4.7977 16.369-6.1385 20.931-1.3408 4.5628-4.033 13.81-5.9826 20.549-4.304 14.877-6.136 20.889-7.3886 24.25-2.1371 5.7334-2.5723 6.3292-4.9216 6.7384-0.88855 0.15472-2.4102 0.28196-3.3815 0.28275-2.1993 3e-3 -3.5494 0.36339-4.0558 1.0863-0.42176 0.60215-0.56421 4.8802-0.18251 5.4812 0.20573 0.32388 2.4672 0.37414 23.34 0.51873l8.6151 0.0597-7e-4 -0.95723zm36.751-205.59c4.3282-0.92335 8.4607-4.943 9.4374-9.1796 0.36569-1.5862 0.32543-4.9758-0.077-6.4799-0.85108-3.1813-3.2688-6.291-6.039-7.7675-3.8111-2.0313-9.456-2.0295-13.272 5e-3 -5.9828 3.1888-8.1556 11.089-4.7878 17.408 2.6995 5.0648 8.3611 7.3754 14.738 6.015z" fill="#f0f0f0" stroke-width=".025526"/></g><g transform="matrix(.069892 0 0 -.069892 44.236 474.48)"><path d="m2787 4669c-124-65-123-255 3-319 86-44 196-16 247 62 58 87 26 211-67 258-51 26-132 26-183-1z" fill="#00b6f0" stroke="#00b6f0" stroke-width="4.25"/><path d="m2882 4108c-12-16-63-166-102-303-30-104-101-350-165-565-20-69-58-199-85-290-26-91-64-221-85-290-20-69-58-199-85-290-26-91-64-221-85-290-20-69-57-195-81-280-59-207-93-299-115-310-10-6-35-10-56-10-73 0-84-8-81-54l3-41 228-3 228-2-3 47-3 48-73 3c-66 3-74 5-84 27-13 28 0 104 37 225 13 41 47 156 75 255s66 230 85 290c18 61 56 191 85 290 28 99 66 230 85 290 18 61 56 191 85 290 85 297 123 419 131 429 5 5 17-11 28-35 10-24 192-393 403-819s447-902 523-1058l139-282h168c92 0 168 4 168 8s-75 158-166 342c-588 1183-969 1958-1033 2100-29 63-69 151-89 195-44 95-58 110-80 83z" fill="#575757"/></g></svg>
|
After Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
Before Width: | Height: | Size: 37 KiB |
Binary file not shown.
Before Width: | Height: | Size: 85 KiB |
Binary file not shown.
Before Width: | Height: | Size: 146 KiB |
@ -1,38 +0,0 @@
|
|||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"color" : {
|
|
||||||
"color-space" : "srgb",
|
|
||||||
"components" : {
|
|
||||||
"alpha" : "1.000",
|
|
||||||
"blue" : "1.000",
|
|
||||||
"green" : "1.000",
|
|
||||||
"red" : "1.000"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"idiom" : "universal"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"appearances" : [
|
|
||||||
{
|
|
||||||
"appearance" : "luminosity",
|
|
||||||
"value" : "dark"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"color" : {
|
|
||||||
"color-space" : "srgb",
|
|
||||||
"components" : {
|
|
||||||
"alpha" : "1.000",
|
|
||||||
"blue" : "0.110",
|
|
||||||
"green" : "0.110",
|
|
||||||
"red" : "0.118"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
@ -9,35 +9,29 @@ struct ChannelAvatarView: View {
|
|||||||
@ObservedObject private var accounts = AccountsModel.shared
|
@ObservedObject private var accounts = AccountsModel.shared
|
||||||
@ObservedObject private var subscribedChannels = SubscribedChannelsModel.shared
|
@ObservedObject private var subscribedChannels = SubscribedChannelsModel.shared
|
||||||
|
|
||||||
@State private var url: URL?
|
|
||||||
@State private var loaded = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .bottomTrailing) {
|
ZStack(alignment: .bottomTrailing) {
|
||||||
Group {
|
Group {
|
||||||
Group {
|
Group {
|
||||||
if let url {
|
if let url = channel?.thumbnailURLOrCached {
|
||||||
ThumbnailView(url: url)
|
ThumbnailView(url: url)
|
||||||
} else {
|
} else {
|
||||||
ZStack {
|
ZStack {
|
||||||
if loaded {
|
Color(white: 0.6)
|
||||||
Image(systemName: "person.circle")
|
.opacity(0.5)
|
||||||
.imageScale(.large)
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
} else {
|
|
||||||
Color("PlaceholderColor")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
Group {
|
||||||
if let video, video.isLocal {
|
if let video, video.isLocal {
|
||||||
Image(systemName: video.localStreamImageSystemName)
|
Image(systemName: video.localStreamImageSystemName)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "play.rectangle")
|
||||||
|
}
|
||||||
|
}
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(.accentColor)
|
||||||
.font(.system(size: 20))
|
.font(.system(size: 20))
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.imageScale(.small)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear(perform: updateURL)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
|
|
||||||
@ -55,22 +49,11 @@ struct ChannelAvatarView: View {
|
|||||||
#endif
|
#endif
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.imageScale(.small)
|
.imageScale(.small)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateURL() {
|
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
|
||||||
if let url = channel?.thumbnailURLOrCached {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.url = url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.loaded = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ChannelAvatarView_Previews: PreviewProvider {
|
struct ChannelAvatarView_Previews: PreviewProvider {
|
||||||
|
@ -61,8 +61,7 @@ struct ChannelListItem: View {
|
|||||||
private var label: some View {
|
private var label: some View {
|
||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
VStack {
|
VStack {
|
||||||
ChannelAvatarView(channel: channel, subscribedBadge: false)
|
ChannelAvatarView(channel: channel)
|
||||||
.id("channel-avatar-\(channel.id)")
|
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
.frame(width: 90, height: 90)
|
.frame(width: 90, height: 90)
|
||||||
#else
|
#else
|
||||||
|
@ -53,6 +53,7 @@ struct ChannelPlaylistCell: View {
|
|||||||
|
|
||||||
Text("\(playlist.videosCount ?? playlist.videos.count) videos")
|
Text("\(playlist.videosCount ?? playlist.videos.count) videos")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
.frame(height: 20)
|
.frame(height: 20)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -212,7 +212,6 @@ struct ChannelVideosView: View {
|
|||||||
|
|
||||||
var thumbnail: some View {
|
var thumbnail: some View {
|
||||||
ChannelAvatarView(channel: store.item?.channel)
|
ChannelAvatarView(channel: store.item?.channel)
|
||||||
.id("channel-avatar-\(store.item?.channel?.id ?? "")")
|
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
.frame(width: 80, height: 80, alignment: .trailing)
|
.frame(width: 80, height: 80, alignment: .trailing)
|
||||||
#else
|
#else
|
||||||
@ -339,8 +338,7 @@ struct ChannelVideosView: View {
|
|||||||
private var resource: Resource? {
|
private var resource: Resource? {
|
||||||
guard let channel = presentedChannel else { return nil }
|
guard let channel = presentedChannel else { return nil }
|
||||||
|
|
||||||
let tabData = channel.tabs.first { $0.contentType == contentType }?.data
|
let data = contentType != .videos ? channel.tabs.first(where: { $0.contentType == contentType })?.data : nil
|
||||||
let data = contentType != .videos ? tabData : nil
|
|
||||||
let resource = accounts.api.channel(channel.id, contentType: contentType, data: data)
|
let resource = accounts.api.channel(channel.id, contentType: contentType, data: data)
|
||||||
|
|
||||||
if contentType == .videos {
|
if contentType == .videos {
|
||||||
@ -364,7 +362,6 @@ struct ChannelVideosView: View {
|
|||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label("Unsubscribe", systemImage: "xmark.circle")
|
Label("Unsubscribe", systemImage: "xmark.circle")
|
||||||
.help("Unsubscribe")
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.labelStyle(.automatic)
|
.labelStyle(.automatic)
|
||||||
#else
|
#else
|
||||||
@ -380,8 +377,7 @@ struct ChannelVideosView: View {
|
|||||||
navigation.sidebarSectionChanged.toggle()
|
navigation.sidebarSectionChanged.toggle()
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label("Subscribe", systemImage: "star.circle")
|
Label("Subscribe", systemImage: "circle")
|
||||||
.help("Subscribe")
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.labelStyle(.automatic)
|
.labelStyle(.automatic)
|
||||||
#else
|
#else
|
||||||
@ -415,7 +411,6 @@ struct ChannelVideosView: View {
|
|||||||
feed.markChannelAsWatched(channel.id)
|
feed.markChannelAsWatched(channel.id)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Mark channel feed as watched", systemImage: "checkmark.circle.fill")
|
Label("Mark channel feed as watched", systemImage: "checkmark.circle.fill")
|
||||||
.help("Mark channel feed as watched")
|
|
||||||
}
|
}
|
||||||
.disabled(!feed.canMarkAllFeedAsWatched)
|
.disabled(!feed.canMarkAllFeedAsWatched)
|
||||||
}
|
}
|
||||||
@ -426,7 +421,6 @@ struct ChannelVideosView: View {
|
|||||||
feed.markChannelAsUnwatched(channel.id)
|
feed.markChannelAsUnwatched(channel.id)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Mark channel feed as unwatched", systemImage: "checkmark.circle")
|
Label("Mark channel feed as unwatched", systemImage: "checkmark.circle")
|
||||||
.help("Mark channel feed as unwatched")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -457,8 +451,7 @@ struct ChannelVideosView: View {
|
|||||||
next = next ?? ""
|
next = next ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
let tabData = channel.tabs.first { $0.contentType == contentType }?.data
|
let data = contentType != .videos ? channel.tabs.first(where: { $0.contentType == contentType })?.data : nil
|
||||||
let data = contentType != .videos ? tabData : nil
|
|
||||||
accounts.api.channel(channel.id, contentType: contentType, data: data, page: next).load().onSuccess { response in
|
accounts.api.channel(channel.id, contentType: contentType, data: data, page: next).load().onSuccess { response in
|
||||||
if let page: ChannelPage = response.typedContent() {
|
if let page: ChannelPage = response.typedContent() {
|
||||||
self.page = page
|
self.page = page
|
||||||
|
@ -2,27 +2,9 @@ import Defaults
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
enum Constants {
|
struct Constants {
|
||||||
|
static let yatteeProtocol = "yattee://"
|
||||||
static let overlayAnimation = Animation.linear(duration: 0.2)
|
static let overlayAnimation = Animation.linear(duration: 0.2)
|
||||||
static let aspectRatio16x9 = 16.0 / 9.0
|
|
||||||
static let aspectRatio4x3 = 4.0 / 3.0
|
|
||||||
|
|
||||||
static var isAppleTV: Bool {
|
|
||||||
#if os(iOS)
|
|
||||||
UIDevice.current.userInterfaceIdiom == .tv
|
|
||||||
#else
|
|
||||||
false
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
static var isMac: Bool {
|
|
||||||
#if os(iOS)
|
|
||||||
UIDevice.current.userInterfaceIdiom == .mac
|
|
||||||
#else
|
|
||||||
false
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
static var isIPhone: Bool {
|
static var isIPhone: Bool {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
UIDevice.current.userInterfaceIdiom == .phone
|
UIDevice.current.userInterfaceIdiom == .phone
|
||||||
@ -39,38 +21,6 @@ enum Constants {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
static var isTvOS: Bool {
|
|
||||||
#if os(tvOS)
|
|
||||||
true
|
|
||||||
#else
|
|
||||||
false
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
static var isMacOS: Bool {
|
|
||||||
#if os(macOS)
|
|
||||||
true
|
|
||||||
#else
|
|
||||||
false
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
static var isIOS: Bool {
|
|
||||||
#if os(iOS)
|
|
||||||
true
|
|
||||||
#else
|
|
||||||
false
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
static var detailsVisibility: Bool {
|
|
||||||
#if os(iOS)
|
|
||||||
false
|
|
||||||
#else
|
|
||||||
true
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
static var progressViewScale: Double {
|
static var progressViewScale: Double {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
0.4
|
0.4
|
||||||
@ -103,44 +53,11 @@ enum Constants {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
static var contentViewMinWidth: Double {
|
static var detailsVisibility: Bool {
|
||||||
#if os(macOS)
|
#if os(iOS)
|
||||||
835
|
false
|
||||||
#else
|
#else
|
||||||
0
|
true
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
static var deviceName: String {
|
|
||||||
#if os(macOS)
|
|
||||||
Host().localizedName ?? "Mac"
|
|
||||||
#else
|
|
||||||
UIDevice.current.name
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
static var platform: String {
|
|
||||||
#if os(macOS)
|
|
||||||
"macOS"
|
|
||||||
#elseif os(iOS)
|
|
||||||
"iOS"
|
|
||||||
#elseif os(tvOS)
|
|
||||||
"tvOS"
|
|
||||||
#else
|
|
||||||
"unknown"
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
static var defaultNavigationStyle: NavigationStyle {
|
|
||||||
#if os(macOS)
|
|
||||||
return .sidebar
|
|
||||||
#elseif os(iOS)
|
|
||||||
if isIPad {
|
|
||||||
return .sidebar
|
|
||||||
}
|
|
||||||
return .tab
|
|
||||||
#else
|
|
||||||
return .tab
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,123 +6,122 @@ import SwiftUI
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
extension Defaults.Keys {
|
extension Defaults.Keys {
|
||||||
// MARK: GROUP - Browsing
|
static let instancesManifest = Key<String>("instancesManifest", default: "")
|
||||||
|
static let countryOfPublicInstances = Key<String?>("countryOfPublicInstances")
|
||||||
|
|
||||||
|
static let instances = Key<[Instance]>("instances", default: [])
|
||||||
|
static let accounts = Key<[Account]>("accounts", default: [])
|
||||||
|
static let lastAccountID = Key<Account.ID?>("lastAccountID")
|
||||||
|
static let lastInstanceID = Key<Instance.ID?>("lastInstanceID")
|
||||||
|
static let lastUsedPlaylistID = Key<Playlist.ID?>("lastPlaylistID")
|
||||||
|
static let lastAccountIsPublic = Key<Bool>("lastAccountIsPublic", default: false)
|
||||||
|
|
||||||
|
static let sponsorBlockInstance = Key<String>("sponsorBlockInstance", default: "https://sponsor.ajay.app")
|
||||||
|
static let sponsorBlockCategories = Key<Set<String>>("sponsorBlockCategories", default: Set(SponsorBlockAPI.categories))
|
||||||
|
|
||||||
|
static let enableReturnYouTubeDislike = Key<Bool>("enableReturnYouTubeDislike", default: false)
|
||||||
|
|
||||||
static let showHome = Key<Bool>("showHome", default: true)
|
static let showHome = Key<Bool>("showHome", default: true)
|
||||||
static let showOpenActionsInHome = Key<Bool>("showOpenActionsInHome", default: true)
|
static let showOpenActionsInHome = Key<Bool>("showOpenActionsInHome", default: true)
|
||||||
static let showQueueInHome = Key<Bool>("showQueueInHome", default: true)
|
static let showQueueInHome = Key<Bool>("showQueueInHome", default: true)
|
||||||
static let showFavoritesInHome = Key<Bool>("showFavoritesInHome", default: true)
|
|
||||||
static let favorites = Key<[FavoriteItem]>("favorites", default: [])
|
|
||||||
static let widgetsSettings = Key<[WidgetSettings]>("widgetsSettings", default: [])
|
|
||||||
static let startupSection = Key<StartupSection>("startupSection", default: .home)
|
|
||||||
static let showSearchSuggestions = Key<Bool>("showSearchSuggestions", default: true)
|
|
||||||
static let visibleSections = Key<Set<VisibleSection>>("visibleSections", default: [.subscriptions, .trending, .playlists])
|
|
||||||
|
|
||||||
static let showOpenActionsToolbarItem = Key<Bool>("showOpenActionsToolbarItem", default: false)
|
static let showOpenActionsToolbarItem = Key<Bool>("showOpenActionsToolbarItem", default: false)
|
||||||
|
static let showFavoritesInHome = Key<Bool>("showFavoritesInHome", default: true)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
static let showDocuments = Key<Bool>("showDocuments", default: false)
|
static let showDocuments = Key<Bool>("showDocuments", default: false)
|
||||||
static let lockPortraitWhenBrowsing = Key<Bool>("lockPortraitWhenBrowsing", default: Constants.isIPhone)
|
|
||||||
#endif
|
#endif
|
||||||
|
static let homeHistoryItems = Key<Int>("homeHistoryItems", default: 10)
|
||||||
|
static let favorites = Key<[FavoriteItem]>("favorites", default: [])
|
||||||
|
|
||||||
|
static let playerButtonSingleTapGesture = Key<PlayerTapGestureAction>("playerButtonSingleTapGesture", default: .togglePlayer)
|
||||||
|
static let playerButtonDoubleTapGesture = Key<PlayerTapGestureAction>("playerButtonDoubleTapGesture", default: .nothing)
|
||||||
|
static let playerButtonShowsControlButtonsWhenMinimized = Key<Bool>("playerButtonShowsControlButtonsWhenMinimized", default: false)
|
||||||
|
static let playerButtonIsExpanded = Key<Bool>("playerButtonIsExpanded", default: false)
|
||||||
|
static let playerBarMaxWidth = Key<String>("playerBarMaxWidth", default: "600")
|
||||||
|
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
static let accountPickerDisplaysUsernameDefault = true
|
static let accountPickerDisplaysUsernameDefault = true
|
||||||
#else
|
#else
|
||||||
static let accountPickerDisplaysUsernameDefault = Constants.isIPad
|
static let accountPickerDisplaysUsernameDefault = UIDevice.current.userInterfaceIdiom == .pad
|
||||||
#endif
|
#endif
|
||||||
static let accountPickerDisplaysUsername = Key<Bool>("accountPickerDisplaysUsername", default: accountPickerDisplaysUsernameDefault)
|
static let accountPickerDisplaysUsername = Key<Bool>("accountPickerDisplaysUsername", default: accountPickerDisplaysUsernameDefault)
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
static let accountPickerDisplaysAnonymousAccounts = Key<Bool>("accountPickerDisplaysAnonymousAccounts", default: true)
|
static let accountPickerDisplaysAnonymousAccounts = Key<Bool>("accountPickerDisplaysAnonymousAccounts", default: true)
|
||||||
|
#if os(iOS)
|
||||||
|
static let lockPortraitWhenBrowsing = Key<Bool>("lockPortraitWhenBrowsing", default: UIDevice.current.userInterfaceIdiom == .phone)
|
||||||
|
#endif
|
||||||
static let showUnwatchedFeedBadges = Key<Bool>("showUnwatchedFeedBadges", default: false)
|
static let showUnwatchedFeedBadges = Key<Bool>("showUnwatchedFeedBadges", default: false)
|
||||||
static let expandChannelDescription = Key<Bool>("expandChannelDescription", default: false)
|
|
||||||
|
|
||||||
static let keepChannelsWithUnwatchedFeedOnTop = Key<Bool>("keepChannelsWithUnwatchedFeedOnTop", default: true)
|
static let keepChannelsWithUnwatchedFeedOnTop = Key<Bool>("keepChannelsWithUnwatchedFeedOnTop", default: true)
|
||||||
static let showChannelAvatarInChannelsLists = Key<Bool>("showChannelAvatarInChannelsLists", default: true)
|
static let showToggleWatchedStatusButton = Key<Bool>("showToggleWatchedStatusButton", default: false)
|
||||||
static let showChannelAvatarInVideosListing = Key<Bool>("showChannelAvatarInVideosListing", default: true)
|
static let expandChannelDescription = Key<Bool>("expandChannelDescription", default: false)
|
||||||
|
|
||||||
static let playerButtonSingleTapGesture = Key<PlayerTapGestureAction>("playerButtonSingleTapGesture", default: .togglePlayer)
|
|
||||||
static let playerButtonDoubleTapGesture = Key<PlayerTapGestureAction>("playerButtonDoubleTapGesture", default: .togglePlayerVisibility)
|
|
||||||
static let playerButtonShowsControlButtonsWhenMinimized = Key<Bool>("playerButtonShowsControlButtonsWhenMinimized", default: true)
|
|
||||||
static let playerButtonIsExpanded = Key<Bool>("playerButtonIsExpanded", default: true)
|
|
||||||
static let playerBarMaxWidth = Key<String>("playerBarMaxWidth", default: "600")
|
|
||||||
static let channelOnThumbnail = Key<Bool>("channelOnThumbnail", default: false)
|
static let channelOnThumbnail = Key<Bool>("channelOnThumbnail", default: false)
|
||||||
static let timeOnThumbnail = Key<Bool>("timeOnThumbnail", default: true)
|
static let timeOnThumbnail = Key<Bool>("timeOnThumbnail", default: true)
|
||||||
static let roundedThumbnails = Key<Bool>("roundedThumbnails", default: true)
|
static let roundedThumbnails = Key<Bool>("roundedThumbnails", default: true)
|
||||||
static let thumbnailsQuality = Key<ThumbnailsQuality>("thumbnailsQuality", default: .highest)
|
static let thumbnailsQuality = Key<ThumbnailsQuality>("thumbnailsQuality", default: .highest)
|
||||||
|
|
||||||
// MARK: GROUP - Player
|
static let captionsLanguageCode = Key<String?>("captionsLanguageCode")
|
||||||
|
static let activeBackend = Key<PlayerBackendType>("activeBackend", default: .mpv)
|
||||||
|
|
||||||
|
static let hd2160pMPVProfile = QualityProfile(id: "hd2160pMPVProfile", backend: .mpv, resolution: .hd2160p60, formats: QualityProfile.Format.allCases)
|
||||||
|
static let hd1080pMPVProfile = QualityProfile(id: "hd1080pMPVProfile", backend: .mpv, resolution: .hd1080p60, formats: QualityProfile.Format.allCases)
|
||||||
|
static let hd720pMPVProfile = QualityProfile(id: "hd720pMPVProfile", backend: .mpv, resolution: .hd720p60, formats: QualityProfile.Format.allCases)
|
||||||
|
static let hd720pAVPlayerProfile = QualityProfile(id: "hd720pAVPlayerProfile", backend: .appleAVPlayer, resolution: .hd720p60, formats: [.hls, .stream])
|
||||||
|
static let sd360pAVPlayerProfile = QualityProfile(id: "sd360pAVPlayerProfile", backend: .appleAVPlayer, resolution: .sd360p30, formats: [.hls, .stream])
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
static let qualityProfilesDefault = UIDevice.current.userInterfaceIdiom == .pad ? [
|
||||||
|
hd2160pMPVProfile,
|
||||||
|
hd1080pMPVProfile,
|
||||||
|
hd720pMPVProfile,
|
||||||
|
hd720pAVPlayerProfile,
|
||||||
|
sd360pAVPlayerProfile
|
||||||
|
] : [
|
||||||
|
hd1080pMPVProfile,
|
||||||
|
hd720pMPVProfile,
|
||||||
|
hd720pAVPlayerProfile,
|
||||||
|
sd360pAVPlayerProfile
|
||||||
|
]
|
||||||
|
|
||||||
|
static let batteryCellularProfileDefault = hd720pAVPlayerProfile.id
|
||||||
|
static let batteryNonCellularProfileDefault = hd720pAVPlayerProfile.id
|
||||||
|
static let chargingCellularProfileDefault = hd720pAVPlayerProfile.id
|
||||||
|
static let chargingNonCellularProfileDefault = hd1080pMPVProfile.id
|
||||||
|
#elseif os(tvOS)
|
||||||
|
static let qualityProfilesDefault = [
|
||||||
|
hd2160pMPVProfile,
|
||||||
|
hd1080pMPVProfile,
|
||||||
|
hd720pMPVProfile,
|
||||||
|
hd720pAVPlayerProfile
|
||||||
|
]
|
||||||
|
static let batteryCellularProfileDefault = hd1080pMPVProfile.id
|
||||||
|
static let batteryNonCellularProfileDefault = hd1080pMPVProfile.id
|
||||||
|
static let chargingCellularProfileDefault = hd1080pMPVProfile.id
|
||||||
|
static let chargingNonCellularProfileDefault = hd1080pMPVProfile.id
|
||||||
|
#else
|
||||||
|
static let qualityProfilesDefault = [
|
||||||
|
hd2160pMPVProfile,
|
||||||
|
hd1080pMPVProfile,
|
||||||
|
hd720pMPVProfile,
|
||||||
|
hd720pAVPlayerProfile
|
||||||
|
]
|
||||||
|
static let batteryCellularProfileDefault = hd1080pMPVProfile.id
|
||||||
|
static let batteryNonCellularProfileDefault = hd1080pMPVProfile.id
|
||||||
|
static let chargingCellularProfileDefault = hd1080pMPVProfile.id
|
||||||
|
static let chargingNonCellularProfileDefault = hd1080pMPVProfile.id
|
||||||
|
#endif
|
||||||
|
static let playerRate = Key<Double>("playerRate", default: 1.0)
|
||||||
|
static let qualityProfiles = Key<[QualityProfile]>("qualityProfiles", default: qualityProfilesDefault)
|
||||||
|
static let batteryCellularProfile = Key<QualityProfile.ID>("batteryCellularProfile", default: batteryCellularProfileDefault)
|
||||||
|
static let batteryNonCellularProfile = Key<QualityProfile.ID>("batteryNonCellularProfile", default: batteryNonCellularProfileDefault)
|
||||||
|
static let chargingCellularProfile = Key<QualityProfile.ID>("chargingCellularProfile", default: chargingCellularProfileDefault)
|
||||||
|
static let chargingNonCellularProfile = Key<QualityProfile.ID>("chargingNonCellularProfile", default: chargingNonCellularProfileDefault)
|
||||||
|
static let forceAVPlayerForLiveStreams = Key<Bool>("forceAVPlayerForLiveStreams", default: true)
|
||||||
|
static let playerSidebar = Key<PlayerSidebarSetting>("playerSidebar", default: .defaultValue)
|
||||||
static let playerInstanceID = Key<Instance.ID?>("playerInstance")
|
static let playerInstanceID = Key<Instance.ID?>("playerInstance")
|
||||||
|
|
||||||
#if os(tvOS)
|
|
||||||
static let pauseOnHidingPlayerDefault = true
|
|
||||||
#else
|
|
||||||
static let pauseOnHidingPlayerDefault = false
|
|
||||||
#endif
|
|
||||||
static let pauseOnHidingPlayer = Key<Bool>("pauseOnHidingPlayer", default: pauseOnHidingPlayerDefault)
|
|
||||||
|
|
||||||
static let closeVideoOnEOF = Key<Bool>("closeVideoOnEOF", default: false)
|
|
||||||
|
|
||||||
#if !os(macOS)
|
|
||||||
static let pauseOnEnteringBackground = Key<Bool>("pauseOnEnteringBackground", default: false)
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
static let expandVideoDescriptionDefault = Constants.isIPad
|
static let playerControlsLayoutDefault = UIDevice.current.userInterfaceIdiom == .pad ? PlayerControlsLayout.medium : .small
|
||||||
#else
|
static let fullScreenPlayerControlsLayoutDefault = UIDevice.current.userInterfaceIdiom == .pad ? PlayerControlsLayout.medium : .small
|
||||||
static let expandVideoDescriptionDefault = true
|
|
||||||
#endif
|
|
||||||
static let expandVideoDescription = Key<Bool>("expandVideoDescription", default: expandVideoDescriptionDefault)
|
|
||||||
|
|
||||||
static let collapsedLinesDescription = Key<Int>("collapsedLinesDescription", default: 5)
|
|
||||||
static let exitFullscreenOnEOF = Key<Bool>("exitFullscreenOnEOF", default: true)
|
|
||||||
|
|
||||||
static let showChapters = Key<Bool>("showChapters", default: true)
|
|
||||||
static let showChapterThumbnails = Key<Bool>("showChapterThumbnails", default: true)
|
|
||||||
static let showChapterThumbnailsOnlyWhenDifferent = Key<Bool>("showChapterThumbnailsOnlyWhenDifferent", default: false)
|
|
||||||
static let expandChapters = Key<Bool>("expandChapters", default: true)
|
|
||||||
static let showRelated = Key<Bool>("showRelated", default: true)
|
|
||||||
static let showInspector = Key<ShowInspectorSetting>("showInspector", default: .onlyLocal)
|
|
||||||
|
|
||||||
static let playerSidebar = Key<PlayerSidebarSetting>("playerSidebar", default: .defaultValue)
|
|
||||||
static let showKeywords = Key<Bool>("showKeywords", default: false)
|
|
||||||
static let showComments = Key<Bool>("showComments", default: true)
|
|
||||||
#if !os(tvOS)
|
|
||||||
static let showScrollToTopInComments = Key<Bool>("showScrollToTopInComments", default: true)
|
|
||||||
#endif
|
|
||||||
static let enableReturnYouTubeDislike = Key<Bool>("enableReturnYouTubeDislike", default: false)
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
static let isOrientationLocked = Key<Bool>("isOrientationLocked", default: Constants.isIPhone)
|
|
||||||
static let enterFullscreenInLandscape = Key<Bool>("enterFullscreenInLandscape", default: Constants.isIPhone)
|
|
||||||
static let rotateToLandscapeOnEnterFullScreen = Key<FullScreenRotationSetting>("rotateToLandscapeOnEnterFullScreen", default: .landscapeRight)
|
|
||||||
#endif
|
|
||||||
|
|
||||||
static let closePiPOnNavigation = Key<Bool>("closePiPOnNavigation", default: false)
|
|
||||||
static let closePiPOnOpeningPlayer = Key<Bool>("closePiPOnOpeningPlayer", default: false)
|
|
||||||
static let closePlayerOnOpeningPiP = Key<Bool>("closePlayerOnOpeningPiP", default: false)
|
|
||||||
#if !os(macOS)
|
|
||||||
static let closePiPAndOpenPlayerOnEnteringForeground = Key<Bool>("closePiPAndOpenPlayerOnEnteringForeground", default: false)
|
|
||||||
#endif
|
|
||||||
|
|
||||||
static let captionsAutoShow = Key<Bool>("captionsAutoShow", default: false)
|
|
||||||
static let captionsDefaultLanguageCode = Key<String>("captionsDefaultLanguageCode", default: LanguageCodes.English.rawValue)
|
|
||||||
static let captionsFallbackLanguageCode = Key<String>("captionsDefaultFallbackCode", default: LanguageCodes.English.rawValue)
|
|
||||||
static let captionsFontScaleSize = Key<String>("captionsFontScale", default: "1.0")
|
|
||||||
static let captionsFontColor = Key<String>("captionsFontColor", default: "#FFFFFF")
|
|
||||||
|
|
||||||
// MARK: GROUP - Controls
|
|
||||||
|
|
||||||
static let avPlayerUsesSystemControls = Key<Bool>("avPlayerUsesSystemControls", default: Constants.isTvOS)
|
|
||||||
static let horizontalPlayerGestureEnabled = Key<Bool>("horizontalPlayerGestureEnabled", default: true)
|
|
||||||
static let fullscreenPlayerGestureEnabled = Key<Bool>("fullscreenPlayerGestureEnabled", default: true)
|
|
||||||
static let seekGestureSensitivity = Key<Double>("seekGestureSensitivity", default: 30.0)
|
|
||||||
static let seekGestureSpeed = Key<Double>("seekGestureSpeed", default: 0.5)
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
static let playerControlsLayoutDefault = Constants.isIPad ? PlayerControlsLayout.medium : .small
|
|
||||||
static let fullScreenPlayerControlsLayoutDefault = Constants.isIPad ? PlayerControlsLayout.medium : .small
|
|
||||||
#elseif os(tvOS)
|
#elseif os(tvOS)
|
||||||
static let playerControlsLayoutDefault = PlayerControlsLayout.tvRegular
|
static let playerControlsLayoutDefault = PlayerControlsLayout.tvRegular
|
||||||
static let fullScreenPlayerControlsLayoutDefault = PlayerControlsLayout.tvRegular
|
static let fullScreenPlayerControlsLayoutDefault = PlayerControlsLayout.tvRegular
|
||||||
@ -133,7 +132,80 @@ extension Defaults.Keys {
|
|||||||
|
|
||||||
static let playerControlsLayout = Key<PlayerControlsLayout>("playerControlsLayout", default: playerControlsLayoutDefault)
|
static let playerControlsLayout = Key<PlayerControlsLayout>("playerControlsLayout", default: playerControlsLayoutDefault)
|
||||||
static let fullScreenPlayerControlsLayout = Key<PlayerControlsLayout>("fullScreenPlayerControlsLayout", default: fullScreenPlayerControlsLayoutDefault)
|
static let fullScreenPlayerControlsLayout = Key<PlayerControlsLayout>("fullScreenPlayerControlsLayout", default: fullScreenPlayerControlsLayoutDefault)
|
||||||
static let playerControlsBackgroundOpacity = Key<Double>("playerControlsBackgroundOpacity", default: 0.2)
|
static let avPlayerUsesSystemControls = Key<Bool>("avPlayerUsesSystemControls", default: true)
|
||||||
|
static let horizontalPlayerGestureEnabled = Key<Bool>("horizontalPlayerGestureEnabled", default: true)
|
||||||
|
static let seekGestureSpeed = Key<Double>("seekGestureSpeed", default: 0.5)
|
||||||
|
static let seekGestureSensitivity = Key<Double>("seekGestureSensitivity", default: 30.0)
|
||||||
|
static let showKeywords = Key<Bool>("showKeywords", default: false)
|
||||||
|
#if !os(tvOS)
|
||||||
|
static let showScrollToTopInComments = Key<Bool>("showScrollToTopInComments", default: true)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
static let expandVideoDescriptionDefault = Constants.isIPad
|
||||||
|
#else
|
||||||
|
static let expandVideoDescriptionDefault = true
|
||||||
|
#endif
|
||||||
|
static let expandVideoDescription = Key<Bool>("expandVideoDescription", default: expandVideoDescriptionDefault)
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
static let pauseOnHidingPlayerDefault = true
|
||||||
|
#else
|
||||||
|
static let pauseOnHidingPlayerDefault = false
|
||||||
|
#endif
|
||||||
|
static let pauseOnHidingPlayer = Key<Bool>("pauseOnHidingPlayer", default: pauseOnHidingPlayerDefault)
|
||||||
|
|
||||||
|
#if !os(macOS)
|
||||||
|
static let pauseOnEnteringBackground = Key<Bool>("pauseOnEnteringBackground", default: true)
|
||||||
|
#endif
|
||||||
|
static let closeVideoOnEOF = Key<Bool>("closeVideoOnEOF", default: false)
|
||||||
|
static let closePiPOnNavigation = Key<Bool>("closePiPOnNavigation", default: false)
|
||||||
|
static let closePiPOnOpeningPlayer = Key<Bool>("closePiPOnOpeningPlayer", default: false)
|
||||||
|
#if !os(macOS)
|
||||||
|
static let closePiPAndOpenPlayerOnEnteringForeground = Key<Bool>("closePiPAndOpenPlayerOnEnteringForeground", default: false)
|
||||||
|
#endif
|
||||||
|
static let closePlayerOnOpeningPiP = Key<Bool>("closePlayerOnOpeningPiP", default: false)
|
||||||
|
|
||||||
|
static let recentlyOpened = Key<[RecentItem]>("recentlyOpened", default: [])
|
||||||
|
|
||||||
|
static let queue = Key<[PlayerQueueItem]>("queue", default: [])
|
||||||
|
static let saveLastPlayed = Key<Bool>("saveLastPlayed", default: false)
|
||||||
|
static let lastPlayed = Key<PlayerQueueItem?>("lastPlayed")
|
||||||
|
static let playbackMode = Key<PlayerModel.PlaybackMode>("playbackMode", default: .queue)
|
||||||
|
|
||||||
|
static let saveHistory = Key<Bool>("saveHistory", default: true)
|
||||||
|
static let showWatchingProgress = Key<Bool>("showWatchingProgress", default: true)
|
||||||
|
static let watchedThreshold = Key<Int>("watchedThreshold", default: 90)
|
||||||
|
static let watchedVideoStyle = Key<WatchedVideoStyle>("watchedVideoStyle", default: .badge)
|
||||||
|
static let watchedVideoBadgeColor = Key<WatchedVideoBadgeColor>("WatchedVideoBadgeColor", default: .red)
|
||||||
|
static let watchedVideoPlayNowBehavior = Key<WatchedVideoPlayNowBehavior>("watchedVideoPlayNowBehavior", default: .continue)
|
||||||
|
static let resetWatchedStatusOnPlaying = Key<Bool>("resetWatchedStatusOnPlaying", default: false)
|
||||||
|
static let saveRecents = Key<Bool>("saveRecents", default: true)
|
||||||
|
|
||||||
|
static let trendingCategory = Key<TrendingCategory>("trendingCategory", default: .default)
|
||||||
|
static let trendingCountry = Key<Country>("trendingCountry", default: .us)
|
||||||
|
|
||||||
|
static let visibleSections = Key<Set<VisibleSection>>("visibleSections", default: [.subscriptions, .trending, .playlists])
|
||||||
|
static let startupSection = Key<StartupSection>("startupSection", default: .home)
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
static let honorSystemOrientationLock = Key<Bool>("honorSystemOrientationLock", default: true)
|
||||||
|
static let enterFullscreenInLandscape = Key<Bool>("enterFullscreenInLandscape", default: UIDevice.current.userInterfaceIdiom == .phone)
|
||||||
|
static let rotateToLandscapeOnEnterFullScreen = Key<FullScreenRotationSetting>(
|
||||||
|
"rotateToLandscapeOnEnterFullScreen",
|
||||||
|
default: UIDevice.current.userInterfaceIdiom == .phone ? .landscapeRight : .disabled
|
||||||
|
)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
static let showMPVPlaybackStats = Key<Bool>("showMPVPlaybackStats", default: false)
|
||||||
|
static let showPlayNowInBackendContextMenu = Key<Bool>("showPlayNowInBackendContextMenu", default: false)
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
static let playerDetailsPageButtonLabelStyleDefault = ButtonLabelStyle.iconAndText
|
||||||
|
#else
|
||||||
|
static let playerDetailsPageButtonLabelStyleDefault = UIDevice.current.userInterfaceIdiom == .phone ? ButtonLabelStyle.iconOnly : .iconAndText
|
||||||
|
#endif
|
||||||
|
static let playerActionsButtonLabelStyle = Key<ButtonLabelStyle>("playerActionsButtonLabelStyle", default: .iconAndText)
|
||||||
|
|
||||||
static let systemControlsCommands = Key<SystemControlsCommands>("systemControlsCommands", default: .restartAndAdvanceToNext)
|
static let systemControlsCommands = Key<SystemControlsCommands>("systemControlsCommands", default: .restartAndAdvanceToNext)
|
||||||
|
|
||||||
@ -142,6 +214,18 @@ extension Defaults.Keys {
|
|||||||
static let gestureBackwardSeekDuration = Key<String>("gestureBackwardSeekDuration", default: "10")
|
static let gestureBackwardSeekDuration = Key<String>("gestureBackwardSeekDuration", default: "10")
|
||||||
static let gestureForwardSeekDuration = Key<String>("gestureForwardSeekDuration", default: "10")
|
static let gestureForwardSeekDuration = Key<String>("gestureForwardSeekDuration", default: "10")
|
||||||
static let systemControlsSeekDuration = Key<String>("systemControlsBackwardSeekDuration", default: "10")
|
static let systemControlsSeekDuration = Key<String>("systemControlsBackwardSeekDuration", default: "10")
|
||||||
|
static let actionButtonShareEnabled = Key<Bool>("actionButtonShareEnabled", default: true)
|
||||||
|
static let actionButtonAddToPlaylistEnabled = Key<Bool>("actionButtonAddToPlaylistEnabled", default: true)
|
||||||
|
static let actionButtonSubscribeEnabled = Key<Bool>("actionButtonSubscribeEnabled", default: false)
|
||||||
|
static let actionButtonSettingsEnabled = Key<Bool>("actionButtonSettingsEnabled", default: true)
|
||||||
|
static let actionButtonHideEnabled = Key<Bool>("actionButtonHideEnabled", default: false)
|
||||||
|
static let actionButtonCloseEnabled = Key<Bool>("actionButtonCloseEnabled", default: true)
|
||||||
|
static let actionButtonFullScreenEnabled = Key<Bool>("actionButtonFullScreenEnabled", default: false)
|
||||||
|
static let actionButtonPipEnabled = Key<Bool>("actionButtonPipEnabled", default: false)
|
||||||
|
static let actionButtonLockOrientationEnabled = Key<Bool>("actionButtonLockOrientationEnabled", default: false)
|
||||||
|
static let actionButtonRestartEnabled = Key<Bool>("actionButtonRestartEnabled", default: false)
|
||||||
|
static let actionButtonAdvanceToNextItemEnabled = Key<Bool>("actionButtonAdvanceToNextItemEnabled", default: false)
|
||||||
|
static let actionButtonMusicModeEnabled = Key<Bool>("actionButtonMusicModeEnabled", default: true)
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
static let playerControlsLockOrientationEnabled = Key<Bool>("playerControlsLockOrientationEnabled", default: true)
|
static let playerControlsLockOrientationEnabled = Key<Bool>("playerControlsLockOrientationEnabled", default: true)
|
||||||
@ -158,235 +242,13 @@ extension Defaults.Keys {
|
|||||||
static let playerControlsPlaybackModeEnabled = Key<Bool>("playerControlsPlaybackModeEnabled", default: false)
|
static let playerControlsPlaybackModeEnabled = Key<Bool>("playerControlsPlaybackModeEnabled", default: false)
|
||||||
static let playerControlsMusicModeEnabled = Key<Bool>("playerControlsMusicModeEnabled", default: false)
|
static let playerControlsMusicModeEnabled = Key<Bool>("playerControlsMusicModeEnabled", default: false)
|
||||||
|
|
||||||
static let playerActionsButtonLabelStyle = Key<ButtonLabelStyle>("playerActionsButtonLabelStyle", default: .iconAndText)
|
|
||||||
|
|
||||||
static let actionButtonShareEnabled = Key<Bool>("actionButtonShareEnabled", default: true)
|
|
||||||
static let actionButtonAddToPlaylistEnabled = Key<Bool>("actionButtonAddToPlaylistEnabled", default: true)
|
|
||||||
static let actionButtonSubscribeEnabled = Key<Bool>("actionButtonSubscribeEnabled", default: false)
|
|
||||||
static let actionButtonSettingsEnabled = Key<Bool>("actionButtonSettingsEnabled", default: true)
|
|
||||||
static let actionButtonHideEnabled = Key<Bool>("actionButtonHideEnabled", default: false)
|
|
||||||
static let actionButtonCloseEnabled = Key<Bool>("actionButtonCloseEnabled", default: true)
|
|
||||||
static let actionButtonFullScreenEnabled = Key<Bool>("actionButtonFullScreenEnabled", default: false)
|
|
||||||
static let actionButtonPipEnabled = Key<Bool>("actionButtonPipEnabled", default: false)
|
|
||||||
static let actionButtonLockOrientationEnabled = Key<Bool>("actionButtonLockOrientationEnabled", default: false)
|
|
||||||
static let actionButtonRestartEnabled = Key<Bool>("actionButtonRestartEnabled", default: false)
|
|
||||||
static let actionButtonAdvanceToNextItemEnabled = Key<Bool>("actionButtonAdvanceToNextItemEnabled", default: false)
|
|
||||||
static let actionButtonMusicModeEnabled = Key<Bool>("actionButtonMusicModeEnabled", default: true)
|
|
||||||
|
|
||||||
// MARK: GROUP - Quality
|
|
||||||
|
|
||||||
static let hd2160p60MPVProfile = QualityProfile(id: "hd2160p60MPVProfile", backend: .mpv, resolution: .hd2160p60, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
|
|
||||||
static let hd1080p60MPVProfile = QualityProfile(id: "hd1080p60MPVProfile", backend: .mpv, resolution: .hd1080p60, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
|
|
||||||
static let hd1080pMPVProfile = QualityProfile(id: "hd1080pMPVProfile", backend: .mpv, resolution: .hd1080p30, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
|
|
||||||
static let hd720p60MPVProfile = QualityProfile(id: "hd720p60MPVProfile", backend: .mpv, resolution: .hd720p60, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
|
|
||||||
static let hd720pMPVProfile = QualityProfile(id: "hd720pMPVProfile", backend: .mpv, resolution: .hd720p30, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
|
|
||||||
static let sd360pMPVProfile = QualityProfile(id: "sd360pMPVProfile", backend: .mpv, resolution: .sd360p30, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
|
|
||||||
static let hd720pAVPlayerProfile = QualityProfile(id: "hd720pAVPlayerProfile", backend: .appleAVPlayer, resolution: .hd720p30, formats: [.stream, .hls], order: Array(QualityProfile.Format.allCases.indices))
|
|
||||||
static let sd360pAVPlayerProfile = QualityProfile(id: "sd360pAVPlayerProfile", backend: .appleAVPlayer, resolution: .sd360p30, formats: [.stream, .hls], order: Array(QualityProfile.Format.allCases.indices))
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
enum QualityProfiles {
|
|
||||||
// iPad-specific settings
|
|
||||||
enum iPad {
|
|
||||||
static let qualityProfilesDefault = [
|
|
||||||
hd1080p60MPVProfile,
|
|
||||||
hd1080pMPVProfile,
|
|
||||||
hd720p60MPVProfile,
|
|
||||||
hd720pMPVProfile
|
|
||||||
]
|
|
||||||
|
|
||||||
static let batteryCellularProfileDefault = hd720pMPVProfile.id
|
|
||||||
static let batteryNonCellularProfileDefault = hd720p60MPVProfile.id
|
|
||||||
static let chargingCellularProfileDefault = hd1080pMPVProfile.id
|
|
||||||
static let chargingNonCellularProfileDefault = hd1080p60MPVProfile.id
|
|
||||||
}
|
|
||||||
|
|
||||||
// iPhone-specific settings
|
|
||||||
enum iPhone {
|
|
||||||
static let qualityProfilesDefault = [
|
|
||||||
hd1080p60MPVProfile,
|
|
||||||
hd1080pMPVProfile,
|
|
||||||
hd720p60MPVProfile,
|
|
||||||
hd720pMPVProfile,
|
|
||||||
sd360pMPVProfile
|
|
||||||
]
|
|
||||||
|
|
||||||
static let batteryCellularProfileDefault = sd360pMPVProfile.id
|
|
||||||
static let batteryNonCellularProfileDefault = hd720p60MPVProfile.id
|
|
||||||
static let chargingCellularProfileDefault = hd720pMPVProfile.id
|
|
||||||
static let chargingNonCellularProfileDefault = hd1080p60MPVProfile.id
|
|
||||||
}
|
|
||||||
|
|
||||||
// Access the correct profile based on device type
|
|
||||||
static var currentProfile: (qualityProfilesDefault: [QualityProfile], batteryCellularProfileDefault: String, batteryNonCellularProfileDefault: String, chargingCellularProfileDefault: String, chargingNonCellularProfileDefault: String) {
|
|
||||||
if Constants.isIPad {
|
|
||||||
return (
|
|
||||||
qualityProfilesDefault: iPad.qualityProfilesDefault,
|
|
||||||
batteryCellularProfileDefault: iPad.batteryCellularProfileDefault,
|
|
||||||
batteryNonCellularProfileDefault: iPad.batteryNonCellularProfileDefault,
|
|
||||||
chargingCellularProfileDefault: iPad.chargingCellularProfileDefault,
|
|
||||||
chargingNonCellularProfileDefault: iPad.chargingNonCellularProfileDefault
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
qualityProfilesDefault: iPhone.qualityProfilesDefault,
|
|
||||||
batteryCellularProfileDefault: iPhone.batteryCellularProfileDefault,
|
|
||||||
batteryNonCellularProfileDefault: iPhone.batteryNonCellularProfileDefault,
|
|
||||||
chargingCellularProfileDefault: iPhone.chargingCellularProfileDefault,
|
|
||||||
chargingNonCellularProfileDefault: iPhone.chargingNonCellularProfileDefault
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#elseif os(tvOS)
|
|
||||||
enum QualityProfiles {
|
|
||||||
// tvOS-specific settings
|
|
||||||
enum tvOS {
|
|
||||||
static let qualityProfilesDefault = [
|
|
||||||
hd2160p60MPVProfile,
|
|
||||||
hd1080p60MPVProfile,
|
|
||||||
hd720p60MPVProfile,
|
|
||||||
hd720pAVPlayerProfile
|
|
||||||
]
|
|
||||||
|
|
||||||
static let batteryCellularProfileDefault = hd1080p60MPVProfile.id
|
|
||||||
static let batteryNonCellularProfileDefault = hd1080p60MPVProfile.id
|
|
||||||
static let chargingCellularProfileDefault = hd1080p60MPVProfile.id
|
|
||||||
static let chargingNonCellularProfileDefault = hd1080p60MPVProfile.id
|
|
||||||
}
|
|
||||||
|
|
||||||
// Access the correct profile based on device type
|
|
||||||
static var currentProfile: (qualityProfilesDefault: [QualityProfile], batteryCellularProfileDefault: String, batteryNonCellularProfileDefault: String, chargingCellularProfileDefault: String, chargingNonCellularProfileDefault: String) {
|
|
||||||
(
|
|
||||||
qualityProfilesDefault: tvOS.qualityProfilesDefault,
|
|
||||||
batteryCellularProfileDefault: tvOS.batteryCellularProfileDefault,
|
|
||||||
batteryNonCellularProfileDefault: tvOS.batteryNonCellularProfileDefault,
|
|
||||||
chargingCellularProfileDefault: tvOS.chargingCellularProfileDefault,
|
|
||||||
chargingNonCellularProfileDefault: tvOS.chargingNonCellularProfileDefault
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
enum QualityProfiles {
|
|
||||||
// macOS-specific settings
|
|
||||||
enum macOS {
|
|
||||||
static let qualityProfilesDefault = [
|
|
||||||
hd2160p60MPVProfile,
|
|
||||||
hd1080p60MPVProfile,
|
|
||||||
hd1080pMPVProfile,
|
|
||||||
hd720p60MPVProfile
|
|
||||||
]
|
|
||||||
|
|
||||||
static let batteryCellularProfileDefault = hd1080p60MPVProfile.id
|
|
||||||
static let batteryNonCellularProfileDefault = hd1080p60MPVProfile.id
|
|
||||||
static let chargingCellularProfileDefault = hd1080p60MPVProfile.id
|
|
||||||
static let chargingNonCellularProfileDefault = hd1080p60MPVProfile.id
|
|
||||||
}
|
|
||||||
|
|
||||||
// Access the correct profile for other platforms
|
|
||||||
static var currentProfile: (qualityProfilesDefault: [QualityProfile], batteryCellularProfileDefault: String, batteryNonCellularProfileDefault: String, chargingCellularProfileDefault: String, chargingNonCellularProfileDefault: String) {
|
|
||||||
(
|
|
||||||
qualityProfilesDefault: macOS.qualityProfilesDefault,
|
|
||||||
batteryCellularProfileDefault: macOS.batteryCellularProfileDefault,
|
|
||||||
batteryNonCellularProfileDefault: macOS.batteryNonCellularProfileDefault,
|
|
||||||
chargingCellularProfileDefault: macOS.chargingCellularProfileDefault,
|
|
||||||
chargingNonCellularProfileDefault: macOS.chargingNonCellularProfileDefault
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
static let batteryCellularProfile = Key<QualityProfile.ID>(
|
|
||||||
"batteryCellularProfile",
|
|
||||||
default: QualityProfiles.currentProfile.batteryCellularProfileDefault
|
|
||||||
)
|
|
||||||
static let batteryNonCellularProfile = Key<QualityProfile.ID>(
|
|
||||||
"batteryNonCellularProfile",
|
|
||||||
default: QualityProfiles.currentProfile.batteryNonCellularProfileDefault
|
|
||||||
)
|
|
||||||
static let chargingCellularProfile = Key<QualityProfile.ID>(
|
|
||||||
"chargingCellularProfile",
|
|
||||||
default: QualityProfiles.currentProfile.chargingCellularProfileDefault
|
|
||||||
)
|
|
||||||
static let chargingNonCellularProfile = Key<QualityProfile.ID>(
|
|
||||||
"chargingNonCellularProfile",
|
|
||||||
default: QualityProfiles.currentProfile.chargingNonCellularProfileDefault
|
|
||||||
)
|
|
||||||
static let forceAVPlayerForLiveStreams = Key<Bool>(
|
|
||||||
"forceAVPlayerForLiveStreams",
|
|
||||||
default: true
|
|
||||||
)
|
|
||||||
static let qualityProfiles = Key<[QualityProfile]>(
|
|
||||||
"qualityProfiles",
|
|
||||||
default: QualityProfiles.currentProfile.qualityProfilesDefault
|
|
||||||
)
|
|
||||||
|
|
||||||
// MARK: GROUP - History
|
|
||||||
|
|
||||||
static let saveRecents = Key<Bool>("saveRecents", default: true)
|
|
||||||
static let saveHistory = Key<Bool>("saveHistory", default: true)
|
|
||||||
static let showRecents = Key<Bool>("showRecents", default: true)
|
|
||||||
static let limitRecents = Key<Bool>("limitRecents", default: false)
|
|
||||||
static let limitRecentsAmount = Key<Int>("limitRecentsAmount", default: 10)
|
|
||||||
static let showWatchingProgress = Key<Bool>("showWatchingProgress", default: true)
|
|
||||||
static let saveLastPlayed = Key<Bool>("saveLastPlayed", default: false)
|
|
||||||
|
|
||||||
static let watchedVideoPlayNowBehavior = Key<WatchedVideoPlayNowBehavior>("watchedVideoPlayNowBehavior", default: .continue)
|
|
||||||
static let watchedThreshold = Key<Int>("watchedThreshold", default: 90)
|
|
||||||
static let resetWatchedStatusOnPlaying = Key<Bool>("resetWatchedStatusOnPlaying", default: false)
|
|
||||||
|
|
||||||
static let watchedVideoStyle = Key<WatchedVideoStyle>("watchedVideoStyle", default: .badge)
|
|
||||||
static let watchedVideoBadgeColor = Key<WatchedVideoBadgeColor>("WatchedVideoBadgeColor", default: .red)
|
|
||||||
static let showToggleWatchedStatusButton = Key<Bool>("showToggleWatchedStatusButton", default: false)
|
|
||||||
|
|
||||||
// MARK: GROUP - SponsorBlock
|
|
||||||
|
|
||||||
static let sponsorBlockInstance = Key<String>("sponsorBlockInstance", default: "https://sponsor.ajay.app")
|
|
||||||
static let sponsorBlockCategories = Key<Set<String>>("sponsorBlockCategories", default: Set(SponsorBlockAPI.categories))
|
|
||||||
static let sponsorBlockColors = Key<[String: String]>("sponsorBlockColors", default: SponsorBlockColors.dictionary)
|
|
||||||
static let sponsorBlockShowTimeWithSkipsRemoved = Key<Bool>("sponsorBlockShowTimeWithSkipsRemoved", default: false)
|
|
||||||
static let sponsorBlockShowCategoriesInTimeline = Key<Bool>("sponsorBlockShowCategoriesInTimeline", default: true)
|
|
||||||
static let sponsorBlockShowNoticeAfterSkip = Key<Bool>("sponsorBlockShowNoticeAfterSkip", default: true)
|
|
||||||
|
|
||||||
// MARK: GROUP - Locations
|
|
||||||
|
|
||||||
static let instancesManifest = Key<String>("instancesManifest", default: "")
|
|
||||||
static let countryOfPublicInstances = Key<String?>("countryOfPublicInstances")
|
|
||||||
|
|
||||||
static let instances = Key<[Instance]>("instances", default: [])
|
|
||||||
static let accounts = Key<[Account]>("accounts", default: [])
|
|
||||||
|
|
||||||
// MARK: Group - Advanced
|
|
||||||
|
|
||||||
static let showPlayNowInBackendContextMenu = Key<Bool>("showPlayNowInBackendContextMenu", default: false)
|
|
||||||
static let videoLoadingRetryCount = Key<Int>("videoLoadingRetryCount", default: 10)
|
|
||||||
|
|
||||||
static let showMPVPlaybackStats = Key<Bool>("showMPVPlaybackStats", default: false)
|
|
||||||
static let mpvEnableLogging = Key<Bool>("mpvEnableLogging", default: false)
|
|
||||||
static let mpvCacheSecs = Key<String>("mpvCacheSecs", default: "120")
|
static let mpvCacheSecs = Key<String>("mpvCacheSecs", default: "120")
|
||||||
static let mpvCachePauseWait = Key<String>("mpvCachePauseWait", default: "3")
|
static let mpvCachePauseWait = Key<String>("mpvCachePauseWait", default: "3")
|
||||||
static let mpvCachePauseInital = Key<Bool>("mpvCachePauseInitial", default: false)
|
static let mpvEnableLogging = Key<Bool>("mpvEnableLogging", default: false)
|
||||||
static let mpvDeinterlace = Key<Bool>("mpvDeinterlace", default: false)
|
|
||||||
static let mpvHWdec = Key<String>("hwdec", default: "auto-safe")
|
|
||||||
static let mpvDemuxerLavfProbeInfo = Key<String>("mpvDemuxerLavfProbeInfo", default: "no")
|
|
||||||
static let mpvInitialAudioSync = Key<Bool>("mpvInitialAudioSync", default: true)
|
|
||||||
static let mpvSetRefreshToContentFPS = Key<Bool>("mpvSetRefreshToContentFPS", default: false)
|
|
||||||
|
|
||||||
static let showCacheStatus = Key<Bool>("showCacheStatus", default: false)
|
static let showCacheStatus = Key<Bool>("showCacheStatus", default: false)
|
||||||
static let feedCacheSize = Key<String>("feedCacheSize", default: "50")
|
static let feedCacheSize = Key<String>("feedCacheSize", default: "50")
|
||||||
|
|
||||||
// MARK: GROUP - Other exportable
|
|
||||||
|
|
||||||
static let lastAccountID = Key<Account.ID?>("lastAccountID")
|
|
||||||
static let lastInstanceID = Key<Instance.ID?>("lastInstanceID")
|
|
||||||
|
|
||||||
static let playerRate = Key<Double>("playerRate", default: 1.0)
|
|
||||||
static let recentlyOpened = Key<[RecentItem]>("recentlyOpened", default: [])
|
|
||||||
|
|
||||||
static let trendingCategory = Key<TrendingCategory>("trendingCategory", default: .default)
|
|
||||||
static let trendingCountry = Key<Country>("trendingCountry", default: .us)
|
|
||||||
|
|
||||||
static let subscriptionsViewPage = Key<SubscriptionsView.Page>("subscriptionsViewPage", default: .feed)
|
static let subscriptionsViewPage = Key<SubscriptionsView.Page>("subscriptionsViewPage", default: .feed)
|
||||||
|
|
||||||
static let subscriptionsListingStyle = Key<ListingStyle>("subscriptionsListingStyle", default: .cells)
|
static let subscriptionsListingStyle = Key<ListingStyle>("subscriptionsListingStyle", default: .cells)
|
||||||
@ -397,21 +259,8 @@ extension Defaults.Keys {
|
|||||||
static let searchListingStyle = Key<ListingStyle>("searchListingStyle", default: .cells)
|
static let searchListingStyle = Key<ListingStyle>("searchListingStyle", default: .cells)
|
||||||
static let hideShorts = Key<Bool>("hideShorts", default: false)
|
static let hideShorts = Key<Bool>("hideShorts", default: false)
|
||||||
static let hideWatched = Key<Bool>("hideWatched", default: false)
|
static let hideWatched = Key<Bool>("hideWatched", default: false)
|
||||||
|
static let showInspector = Key<ShowInspectorSetting>("showInspector", default: .onlyLocal)
|
||||||
// MARK: GROUP - Not exportable
|
static let widgetsSettings = Key<[WidgetSettings]>("widgetsSettings", default: [])
|
||||||
|
|
||||||
static let queue = Key<[PlayerQueueItem]>("queue", default: [])
|
|
||||||
static let playbackMode = Key<PlayerModel.PlaybackMode>("playbackMode", default: .queue)
|
|
||||||
static let lastPlayed = Key<PlayerQueueItem?>("lastPlayed")
|
|
||||||
|
|
||||||
static let activeBackend = Key<PlayerBackendType>("activeBackend", default: .mpv)
|
|
||||||
static let captionsLanguageCode = Key<String?>("captionsLanguageCode")
|
|
||||||
static let lastUsedPlaylistID = Key<Playlist.ID?>("lastPlaylistID")
|
|
||||||
static let lastAccountIsPublic = Key<Bool>("lastAccountIsPublic", default: false)
|
|
||||||
|
|
||||||
// MARK: LEGACY
|
|
||||||
|
|
||||||
static let homeHistoryItems = Key<Int>("homeHistoryItems", default: 10)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ResolutionSetting: String, CaseIterable, Defaults.Serializable {
|
enum ResolutionSetting: String, CaseIterable, Defaults.Serializable {
|
||||||
@ -428,34 +277,18 @@ enum ResolutionSetting: String, CaseIterable, Defaults.Serializable {
|
|||||||
case sd240p30
|
case sd240p30
|
||||||
case sd144p30
|
case sd144p30
|
||||||
|
|
||||||
var value: Stream.Resolution {
|
var value: Stream.Resolution! {
|
||||||
if let predefined = Stream.Resolution.PredefinedResolution(rawValue: rawValue) {
|
.init(rawValue: rawValue)
|
||||||
return .predefined(predefined)
|
|
||||||
}
|
|
||||||
// Provide a default value of 720p 30
|
|
||||||
return .custom(height: 720, refreshRate: 30)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
let resolution = value
|
switch self {
|
||||||
let height = resolution.height
|
case .hd2160p60:
|
||||||
let refreshRate = resolution.refreshRate
|
return "4K, 60fps"
|
||||||
|
case .hd2160p30:
|
||||||
// Superscript labels
|
return "4K"
|
||||||
let superscript4K = "⁴ᴷ"
|
|
||||||
let superscriptHD = "ᴴᴰ"
|
|
||||||
|
|
||||||
// Special handling for specific resolutions
|
|
||||||
switch height {
|
|
||||||
case 2160:
|
|
||||||
// 4K superscript after the refresh rate
|
|
||||||
return refreshRate == 30 ? "2160p \(superscript4K)" : "2160p\(refreshRate) \(superscript4K)"
|
|
||||||
case 1440, 1080:
|
|
||||||
// HD superscript after the refresh rate
|
|
||||||
return refreshRate == 30 ? "\(height)p \(superscriptHD)" : "\(height)p\(refreshRate) \(superscriptHD)"
|
|
||||||
default:
|
default:
|
||||||
// Default formatting for other resolutions
|
return value.name
|
||||||
return refreshRate == 30 ? "\(height)p" : "\(height)p\(refreshRate)"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -561,26 +394,15 @@ enum ButtonLabelStyle: String, CaseIterable, Defaults.Serializable {
|
|||||||
var text: Bool {
|
var text: Bool {
|
||||||
self == .iconAndText
|
self == .iconAndText
|
||||||
}
|
}
|
||||||
|
|
||||||
var description: String {
|
|
||||||
switch self {
|
|
||||||
case .iconOnly:
|
|
||||||
return "Icon only".localized()
|
|
||||||
case .iconAndText:
|
|
||||||
return "Icon and text".localized()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ThumbnailsQuality: String, CaseIterable, Defaults.Serializable {
|
enum ThumbnailsQuality: String, CaseIterable, Defaults.Serializable {
|
||||||
case highest, high, medium, low
|
case highest, medium, low
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .highest:
|
case .highest:
|
||||||
return "Best quality".localized()
|
return "Highest quality".localized()
|
||||||
case .high:
|
|
||||||
return "High quality".localized()
|
|
||||||
case .medium:
|
case .medium:
|
||||||
return "Medium quality".localized()
|
return "Medium quality".localized()
|
||||||
case .low:
|
case .low:
|
||||||
@ -630,19 +452,26 @@ enum PlayerTapGestureAction: String, CaseIterable, Defaults.Serializable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum FullScreenRotationSetting: String, CaseIterable, Defaults.Serializable {
|
enum FullScreenRotationSetting: String, CaseIterable, Defaults.Serializable {
|
||||||
|
case disabled
|
||||||
case landscapeLeft
|
case landscapeLeft
|
||||||
case landscapeRight
|
case landscapeRight
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
var interfaceOrientation: UIInterfaceOrientation {
|
var interaceOrientation: UIInterfaceOrientation {
|
||||||
switch self {
|
switch self {
|
||||||
case .landscapeLeft:
|
case .landscapeLeft:
|
||||||
return .landscapeLeft
|
return .landscapeLeft
|
||||||
case .landscapeRight:
|
case .landscapeRight:
|
||||||
return .landscapeRight
|
return .landscapeRight
|
||||||
|
default:
|
||||||
|
return .portrait
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
var isRotating: Bool {
|
||||||
|
self != .disabled
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WidgetSettings: Defaults.Serializable {
|
struct WidgetSettings: Defaults.Serializable {
|
||||||
@ -663,7 +492,7 @@ struct WidgetSettings: Defaults.Serializable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func maxLimit(_ style: WidgetListingStyle) -> Int {
|
static func maxLimit(_ style: WidgetListingStyle) -> Int {
|
||||||
maxLimit[style] ?? defaultLimit
|
Self.maxLimit[style] ?? Self.defaultLimit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -701,26 +530,3 @@ enum WidgetListingStyle: String, CaseIterable, Defaults.Serializable {
|
|||||||
case horizontalCells
|
case horizontalCells
|
||||||
case list
|
case list
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SponsorBlockColors: String {
|
|
||||||
case sponsor = "#00D400" // Green
|
|
||||||
case selfpromo = "#FFFF00" // Yellow
|
|
||||||
case interaction = "#CC00FF" // Purple
|
|
||||||
case intro = "#00FFFF" // Cyan
|
|
||||||
case outro = "#0202ED" // Dark Blue
|
|
||||||
case preview = "#008FD6" // Light Blue
|
|
||||||
case filler = "#7300FF" // Violet
|
|
||||||
case music_offtopic = "#FF9900" // Orange
|
|
||||||
|
|
||||||
// Define all cases, can be used to iterate over the colors
|
|
||||||
static let allCases: [SponsorBlockColors] = [Self.sponsor, Self.selfpromo, Self.interaction, Self.intro, Self.outro, Self.preview, Self.filler, Self.music_offtopic]
|
|
||||||
|
|
||||||
// Create a dictionary with the category names as keys and colors as values
|
|
||||||
static let dictionary: [String: String] = {
|
|
||||||
var dict = [String: String]()
|
|
||||||
for item in allCases {
|
|
||||||
dict[String(describing: item)] = item.rawValue
|
|
||||||
}
|
|
||||||
return dict
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum Delay {
|
struct Delay {
|
||||||
@discardableResult static func by(_ interval: TimeInterval, block: @escaping () -> Void) -> Timer {
|
@discardableResult static func by(_ interval: TimeInterval, block: @escaping () -> Void) -> Timer {
|
||||||
Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in block() }
|
Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in block() }
|
||||||
}
|
}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user