1
0
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.
main ... v1.4.5

350 changed files with 8619 additions and 15650 deletions

View File

@ -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

View File

@ -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

View File

@ -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
* dont 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

View File

@ -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

View File

@ -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)
)
} }
} }

View File

@ -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)
} }

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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 ?? ""
] ]

View File

@ -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)

View File

@ -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 }
}
} }

View File

@ -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)
} }
} }

View File

@ -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
} }
} }
}

View File

@ -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))
} }

View File

@ -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

View File

@ -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)
) )

View File

@ -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 []
}
} }

View File

@ -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 {

View File

@ -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
) )

View File

@ -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
) )

View File

@ -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)")

View File

@ -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))
} }

View File

@ -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] {

View File

@ -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
) )

View File

@ -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 }
] ]
} }

View File

@ -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)
] ]
} }

View File

@ -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

View File

@ -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
} }

View File

@ -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 } }
} }

View File

@ -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
} }

View File

@ -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)
}
}
} }

View File

@ -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)

View File

@ -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
} }

View File

@ -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]
]
}
}

View File

@ -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
}
}

View File

@ -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]
]
}
}

View File

@ -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]
]
}
}

View File

@ -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
}
}

View File

@ -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]
]
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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]
]
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}
}

View File

@ -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)
}
}
}
}
}
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}
}

View File

@ -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)
}
}
}
}
}

View File

@ -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)
}
}
}
}
}

View File

@ -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
}
}
}

View File

@ -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 {

View File

@ -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)
} }
} }

View File

@ -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

View File

@ -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)
} }

View File

@ -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)
} }
} }

View File

@ -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")
} }
} }
} }

View File

@ -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)")
}
}
} }

View File

@ -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, &params) < 0 { if mpv_render_context_create(&mpvGL, mpv, &params) < 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,11 +471,12 @@ 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)
func getProcAddress(_: UnsafeMutableRawPointer?, _ name: UnsafePointer<Int8>?) -> UnsafeMutableRawPointer? { func getProcAddress(_: UnsafeMutableRawPointer?, _ name: UnsafePointer<Int8>?) -> UnsafeMutableRawPointer? {

View File

@ -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
} }

View File

@ -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() {

View File

@ -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
} }

View File

@ -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"],

View File

@ -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 {

View File

@ -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)
} }
} }

View File

@ -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
} }

View File

@ -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
} }

View File

@ -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()
} }

View File

@ -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)
} }
} }

View File

@ -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]$"#

View File

@ -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
} }

View File

@ -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() {

View File

@ -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

View File

@ -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
}
} }

View File

@ -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 = []

View File

@ -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) {

View File

@ -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
}
}
}

View File

@ -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:

View File

@ -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 {

View File

@ -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 {

View File

@ -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

View File

@ -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"
} }

View 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

View File

@ -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
}
}

View File

@ -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,23 +49,12 @@ 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 {
static var previews: some View { static var previews: some View {

View File

@ -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

View File

@ -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)
} }
} }

View File

@ -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

View File

@ -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
} }

View File

@ -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
}()
}

View File

@ -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