mirror of
https://github.com/yattee/yattee.git
synced 2025-12-14 03:58:14 +00:00
Compare commits
13 Commits
1.5.2-210
...
chore/upda
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2df1b7138 | ||
|
|
72c0597c06 | ||
|
|
b056f5b608 | ||
|
|
e676830ead | ||
|
|
46317cc2bf | ||
|
|
65347eb1ec | ||
|
|
dd5e0e7eb2 | ||
|
|
3c3244239d | ||
|
|
ffc9862c75 | ||
|
|
6e1f2630ca | ||
|
|
282d63400e | ||
|
|
cd1da69d83 | ||
|
|
6f002545cf |
@@ -1,59 +0,0 @@
|
||||
name: Build and notarize macOS app (macOS 15, Xcode 16.4)
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
APP_NAME: Yattee
|
||||
FASTLANE_USER: ${{ secrets.FASTLANE_USER }}
|
||||
FASTLANE_PASSWORD: ${{ secrets.FASTLANE_PASSWORD }}
|
||||
ITC_TEAM_ID: ${{ secrets.ITC_TEAM_ID }}
|
||||
TEAM_ID: ${{ secrets.TEAM_ID }}
|
||||
DEVELOPER_KEY_ID: ${{ secrets.DEVELOPER_KEY_ID }}
|
||||
DEVELOPER_KEY_ISSUER_ID: ${{ secrets.DEVELOPER_KEY_ISSUER_ID }}
|
||||
DEVELOPER_KEY_CONTENT: ${{ secrets.DEVELOPER_KEY_CONTENT }}
|
||||
TEMP_KEYCHAIN_USER: ${{ secrets.TEMP_KEYCHAIN_USER }}
|
||||
TEMP_KEYCHAIN_PASSWORD: ${{ secrets.TEMP_KEYCHAIN_PASSWORD }}
|
||||
DEVELOPER_APP_IDENTIFIER: ${{ secrets.DEVELOPER_APP_IDENTIFIER }}
|
||||
GIT_AUTHORIZATION: ${{ secrets.GIT_AUTHORIZATION }}
|
||||
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
|
||||
CERTIFICATES_GIT_URL: ${{ secrets.CERTIFICATES_GIT_URL }}
|
||||
|
||||
jobs:
|
||||
mac_notarized:
|
||||
name: Build and notarize macOS app (macOS 15, Xcode 16.4)
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.1'
|
||||
bundler-cache: true
|
||||
cache-version: 1
|
||||
- name: Replace signing certificate to Direct with Developer ID
|
||||
run: |
|
||||
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
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: '16.4'
|
||||
- name: Clear SPM cache
|
||||
run: |
|
||||
rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
|
||||
rm -rf ~/Library/Developer/Xcode/DerivedData
|
||||
rm -rf .build
|
||||
- uses: maierj/fastlane-action@v3.0.0
|
||||
with:
|
||||
lane: mac build_and_notarize
|
||||
- run: |
|
||||
echo "BUILD_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 CURRENT_PROJECT_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
|
||||
echo "VERSION_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 MARKETING_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
|
||||
- run: |
|
||||
echo "APP_PATH=fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS/Yattee.app" >> $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
|
||||
run: /usr/bin/ditto -c -k --keepParent ${{ env.APP_PATH }} ${{ env.ZIP_PATH }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mac-notarized-build
|
||||
path: ${{ env.ZIP_PATH }}
|
||||
if-no-files-found: error
|
||||
7
.github/workflows/bump-build.yml
vendored
7
.github/workflows/bump-build.yml
vendored
@@ -10,22 +10,21 @@ jobs:
|
||||
name: Bump build number
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v3
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.1'
|
||||
ruby-version: '3.0'
|
||||
bundler-cache: true
|
||||
cache-version: 1
|
||||
- uses: maierj/fastlane-action@v3.0.0
|
||||
with:
|
||||
lane: bump_build
|
||||
- run: echo "BUILD_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 CURRENT_PROJECT_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@v4
|
||||
with:
|
||||
token: ${{ secrets.GIT_AUTHORIZATION }}
|
||||
branch: actions/bump-build-to-${{ env.BUILD_NUMBER }}
|
||||
|
||||
28
.github/workflows/release.yml
vendored
28
.github/workflows/release.yml
vendored
@@ -29,25 +29,19 @@ jobs:
|
||||
name: Releasing ${{ matrix.lane }} version to TestFlight
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.1'
|
||||
ruby-version: '3.0'
|
||||
bundler-cache: true
|
||||
cache-version: 1
|
||||
- name: Replace signing certificate to AppStore
|
||||
run: |
|
||||
sed -i '' 's/match Development/match AppStore/' Yattee.xcodeproj/project.pbxproj
|
||||
sed -i '' 's/iPhone Developer/iPhone Distribution/' Yattee.xcodeproj/project.pbxproj
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: '26.0.1'
|
||||
- name: Clear SPM cache
|
||||
run: rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
|
||||
- uses: maierj/fastlane-action@v3.0.0
|
||||
with:
|
||||
lane: ${{ matrix.lane }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ${{ matrix.lane }} build
|
||||
path: fastlane/builds/**/*.ipa
|
||||
@@ -56,21 +50,15 @@ jobs:
|
||||
name: Build and notarize macOS app
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.1'
|
||||
ruby-version: '3.0'
|
||||
bundler-cache: true
|
||||
cache-version: 1
|
||||
- name: Replace signing certificate to Direct with Developer ID
|
||||
run: |
|
||||
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
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: '26.0.1'
|
||||
- name: Clear SPM cache
|
||||
run: rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
|
||||
- uses: maierj/fastlane-action@v3.0.0
|
||||
with:
|
||||
lane: mac build_and_notarize
|
||||
@@ -82,7 +70,7 @@ jobs:
|
||||
echo "ZIP_PATH=fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS/Yattee-${{ env.VERSION_NUMBER }}-macOS.zip" >> $GITHUB_ENV
|
||||
- name: ZIP build
|
||||
run: /usr/bin/ditto -c -k --keepParent ${{ env.APP_PATH }} ${{ env.ZIP_PATH }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: mac notarized build
|
||||
path: ${{ env.ZIP_PATH }}
|
||||
@@ -92,10 +80,10 @@ jobs:
|
||||
name: Create GitHub release
|
||||
runs-on: ubuntu-latest
|
||||
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 "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:
|
||||
path: artifacts
|
||||
- uses: ncipollo/release-action@v1
|
||||
|
||||
@@ -7,16 +7,6 @@ disabled_rules:
|
||||
- number_separator
|
||||
- multiline_arguments
|
||||
- implicit_return
|
||||
- closure_end_indentation
|
||||
- discarded_notification_center_observer # Observer intentionally lives for app lifetime
|
||||
# Disable deprecated rules in favor of their renamed versions
|
||||
- operator_whitespace # renamed to function_name_whitespace
|
||||
- redundant_optional_initialization # renamed to implicit_optional_initialization
|
||||
|
||||
opt_in_rules:
|
||||
- function_name_whitespace
|
||||
- implicit_optional_initialization
|
||||
|
||||
excluded:
|
||||
- Vendor
|
||||
- Tests Apple TV
|
||||
|
||||
@@ -5,8 +5,7 @@ extension Backport where Content: View {
|
||||
#if os(tvOS)
|
||||
content
|
||||
#else
|
||||
// swiftlint:disable:next deployment_target
|
||||
if #available(iOS 15.0, macOS 12.0, *) {
|
||||
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
|
||||
content.badge(count)
|
||||
} else {
|
||||
content
|
||||
|
||||
@@ -3,16 +3,13 @@ import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func listRowSeparator(_ visible: Bool) -> some View {
|
||||
#if !os(tvOS)
|
||||
// swiftlint:disable:next deployment_target
|
||||
if #available(iOS 15.0, macOS 13.0, *) {
|
||||
content
|
||||
.listRowSeparator(visible ? .visible : .hidden)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
#else
|
||||
if #available(iOS 15, macOS 13, *) {
|
||||
content
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
.listRowSeparator(visible ? .visible : .hidden)
|
||||
#endif
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func persistentSystemOverlays(_ visible: Bool) -> some View {
|
||||
// swiftlint:disable:next deployment_target
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) {
|
||||
content.persistentSystemOverlays(visible ? .visible : .hidden)
|
||||
} else {
|
||||
|
||||
@@ -2,7 +2,6 @@ import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func refreshable(action: @Sendable @escaping () async -> Void) -> some View {
|
||||
// swiftlint:disable:next deployment_target
|
||||
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
|
||||
content.refreshable(action: action)
|
||||
} else {
|
||||
|
||||
@@ -3,7 +3,6 @@ import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func scrollContentBackground(_ visibility: Bool) -> some View {
|
||||
// swiftlint:disable:next deployment_target
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) {
|
||||
content.scrollContentBackground(visibility ? .visible : .hidden)
|
||||
} else {
|
||||
|
||||
@@ -3,7 +3,6 @@ import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func scrollDismissesKeyboardImmediately() -> some View {
|
||||
// swiftlint:disable:next deployment_target
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) {
|
||||
content.scrollDismissesKeyboard(.immediately)
|
||||
} else {
|
||||
@@ -12,7 +11,6 @@ extension Backport where Content: View {
|
||||
}
|
||||
|
||||
@ViewBuilder func scrollDismissesKeyboardInteractively() -> some View {
|
||||
// swiftlint:disable:next deployment_target
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) {
|
||||
content.scrollDismissesKeyboard(.interactively)
|
||||
} else {
|
||||
|
||||
@@ -2,8 +2,7 @@ import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func tint(_ color: Color?) -> some View {
|
||||
// swiftlint:disable:next deployment_target
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) {
|
||||
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
|
||||
content.tint(color)
|
||||
} else {
|
||||
content.foregroundColor(color)
|
||||
|
||||
@@ -2,8 +2,7 @@ import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func toolbarBackground(_ color: Color) -> some View {
|
||||
// swiftlint:disable:next deployment_target
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) {
|
||||
if #available(iOS 16, *) {
|
||||
content
|
||||
.toolbarBackground(color, for: .navigationBar)
|
||||
} else {
|
||||
@@ -12,8 +11,7 @@ extension Backport where Content: View {
|
||||
}
|
||||
|
||||
@ViewBuilder func toolbarBackgroundVisibility(_ visible: Bool) -> some View {
|
||||
// swiftlint:disable:next deployment_target
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) {
|
||||
if #available(iOS 16, *) {
|
||||
content
|
||||
.toolbarBackground(visible ? .visible : .hidden, for: .navigationBar)
|
||||
} else {
|
||||
|
||||
@@ -2,8 +2,7 @@ import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func toolbarColorScheme(_ colorScheme: ColorScheme) -> some View {
|
||||
// swiftlint:disable:next deployment_target
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) {
|
||||
if #available(iOS 16, *) {
|
||||
content
|
||||
.toolbarColorScheme(colorScheme, for: .navigationBar)
|
||||
} else {
|
||||
|
||||
54
CHANGELOG.md
54
CHANGELOG.md
@@ -1,50 +1,6 @@
|
||||
## Build 210
|
||||
## Build 177
|
||||
* Updated dependencies (mpvkit 0.37.0)
|
||||
* Updated localizations
|
||||
* Other minor changes and improvements
|
||||
|
||||
## What's Changed
|
||||
|
||||
* Trending and Hide Shorts was disabled due to changes in the video apps API
|
||||
* Fix iPad iOS 18 keyboard dismissal issue in search
|
||||
* Fix audio session interrupting other apps on launch
|
||||
* Fix thumbnail loading for video details
|
||||
* Fix thumbnail aspect ratio to prevent stretching and layout jumps
|
||||
* Fix keyboard shortcut conflict for Show Player command
|
||||
|
||||
## Previous builds
|
||||
|
||||
**Build 209:**
|
||||
* Fix Now Playing controls for both MPV and AVPlayer backends
|
||||
* Fix thumbnail sizing and aspect ratio issues in video cells (#896)
|
||||
* Adjust tvOS video cell dimensions for better layout
|
||||
* Fix playing videos from channel view in modal opened in video player
|
||||
* Fix audio track label showing "Original" instead of "Unknown"
|
||||
* Simplify fullscreen handling for iOS
|
||||
* Add macOS-specific entitlements for MPV backend
|
||||
|
||||
**Build 208:**
|
||||
* Enable resizable windows on iPad
|
||||
* Improve iPad UI behavior and settings layout
|
||||
* Fix horizontal content extending behind sidebar on iPad
|
||||
* Add proper padding to player controls and video details in non-fullscreen iPad windows
|
||||
* Hide orientation lock controls on iPad (not applicable for iPad)
|
||||
* Fix video player overlay to respect window fullscreen state
|
||||
* Allow video player to extend into safe areas
|
||||
* Fix iOS Now Playing Info Center integration for AVPlayer backend
|
||||
* Fix button styling and safe area handling
|
||||
* Fix picker label visibility in settings
|
||||
* Improve video layer rendering
|
||||
* Add macOS 26 compatibility for search UI
|
||||
* Improve playback settings UI controls
|
||||
* Add retry mechanism for file load errors (both MPV and AVPlayer)
|
||||
* Fix MPV player vertical positioning in fullscreen mode
|
||||
* Improve player controls visibility and layout
|
||||
* Add nil safety checks for stream resolution and playback time handling
|
||||
* Refactor dirty region handling in MPV video rendering
|
||||
* Remove verbose logging from MPV rendering
|
||||
* Improve layout stability and reduce unwanted animations
|
||||
* Simplify stream description by removing instance info
|
||||
* Update default visible sections from trending to popular
|
||||
* Update MPVKit dependency
|
||||
* Update Ruby dependencies
|
||||
* Fix SwiftLint and SwiftFormat violations
|
||||
* Fix main actor isolation warnings
|
||||
* Update GitHub Actions to latest macOS and Xcode versions
|
||||
**Big thanks to the past, current and future project contributors!**
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
extension Notification.Name {
|
||||
static let accountConfigurationComplete = Notification.Name("accountConfigurationComplete")
|
||||
}
|
||||
@@ -17,13 +17,13 @@ extension String {
|
||||
|
||||
var outputText = self
|
||||
|
||||
for match in results.reversed() {
|
||||
for rangeIndex in (1 ..< match.numberOfRanges).reversed() {
|
||||
results.reversed().forEach { match in
|
||||
(1 ..< match.numberOfRanges).reversed().forEach { rangeIndex in
|
||||
let matchingGroup: String = (self as NSString).substring(with: match.range(at: rangeIndex))
|
||||
let rangeBounds = match.range(at: rangeIndex)
|
||||
|
||||
guard let range = Range(rangeBounds, in: self) else {
|
||||
continue
|
||||
return
|
||||
}
|
||||
let replacement = replacementStringClosure(matchingGroup) ?? matchingGroup
|
||||
|
||||
|
||||
@@ -6,10 +6,8 @@ extension UIViewController {
|
||||
}
|
||||
|
||||
public class func swizzleHomeIndicatorProperty() {
|
||||
swizzle(
|
||||
origSelector: #selector(getter: UIViewController.prefersHomeIndicatorAutoHidden),
|
||||
withSelector: #selector(getter: UIViewController.swizzle_prefersHomeIndicatorAutoHidden),
|
||||
forClass: UIViewController.self
|
||||
)
|
||||
swizzle(origSelector: #selector(getter: UIViewController.prefersHomeIndicatorAutoHidden),
|
||||
withSelector: #selector(getter: UIViewController.swizzle_prefersHomeIndicatorAutoHidden),
|
||||
forClass: UIViewController.self)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ extension URL {
|
||||
func byReplacingYatteeProtocol(with urlProtocol: String = "https") -> URL! {
|
||||
var urlAbsoluteString = absoluteString
|
||||
|
||||
guard urlAbsoluteString.hasPrefix(Strings.yatteeProtocol) else {
|
||||
guard urlAbsoluteString.hasPrefix(Constants.yatteeProtocol) else {
|
||||
return self
|
||||
}
|
||||
|
||||
urlAbsoluteString = String(urlAbsoluteString.dropFirst(Strings.yatteeProtocol.count))
|
||||
urlAbsoluteString = String(urlAbsoluteString.dropFirst(Constants.yatteeProtocol.count))
|
||||
if absoluteString.contains("://") {
|
||||
return URL(string: urlAbsoluteString)
|
||||
}
|
||||
|
||||
2
Gemfile
2
Gemfile
@@ -1,6 +1,6 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem 'fastlane'
|
||||
gem 'fastlane', git: 'https://github.com/nekrich/fastlane.git', branch: 'fix/match-tvos-devices-fetch'
|
||||
|
||||
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
|
||||
eval_gemfile(plugins_path) if File.exist?(plugins_path)
|
||||
|
||||
218
Gemfile.lock
218
Gemfile.lock
@@ -1,75 +1,9 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
GIT
|
||||
remote: https://github.com/nekrich/fastlane.git
|
||||
revision: d2d51a9af37f9b04a157e78fd25d147cecc89980
|
||||
branch: fix/match-tvos-devices-fetch
|
||||
specs:
|
||||
CFPropertyList (3.0.8)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1182.0)
|
||||
aws-sdk-core (3.237.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.117.0)
|
||||
aws-sdk-core (~> 3, >= 3.234.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.203.1)
|
||||
aws-sdk-core (~> 3, >= 3.234.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.3.0)
|
||||
bigdecimal (3.3.1)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.6.20240107)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (0.112.0)
|
||||
faraday (1.10.4)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
faraday-excon (~> 1.1)
|
||||
faraday-httpclient (~> 1.0)
|
||||
faraday-multipart (~> 1.0)
|
||||
faraday-net_http (~> 1.0)
|
||||
faraday-net_http_persistent (~> 1.0)
|
||||
faraday-patron (~> 1.0)
|
||||
faraday-rack (~> 1.0)
|
||||
faraday-retry (~> 1.0)
|
||||
ruby2_keywords (>= 0.0.4)
|
||||
faraday-cookie_jar (0.0.8)
|
||||
faraday (>= 0.8.0)
|
||||
http-cookie (>= 1.0.0)
|
||||
faraday-em_http (1.0.0)
|
||||
faraday-em_synchrony (1.0.1)
|
||||
faraday-excon (1.1.0)
|
||||
faraday-httpclient (1.0.1)
|
||||
faraday-multipart (1.1.1)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (1.0.2)
|
||||
faraday-net_http_persistent (1.2.0)
|
||||
faraday-patron (1.0.0)
|
||||
faraday-rack (1.0.0)
|
||||
faraday-retry (1.0.3)
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.4.0)
|
||||
fastlane (2.228.0)
|
||||
fastlane (2.219.0)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
@@ -85,7 +19,6 @@ GEM
|
||||
faraday-cookie_jar (~> 0.0.6)
|
||||
faraday_middleware (~> 1.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
fastlane-sirp (>= 1.0.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
@@ -101,7 +34,7 @@ GEM
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
security (= 0.1.5)
|
||||
security (= 0.1.3)
|
||||
simctl (~> 1.6.3)
|
||||
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||
terminal-table (~> 3)
|
||||
@@ -109,10 +42,76 @@ GEM
|
||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||
word_wrap (~> 1.0.0)
|
||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||
xcpretty (~> 0.4.1)
|
||||
xcpretty (~> 0.3.0)
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
fastlane-sirp (1.0.0)
|
||||
sysrandom (~> 1.0)
|
||||
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.6)
|
||||
rexml
|
||||
addressable (2.8.6)
|
||||
public_suffix (>= 2.0.2, < 6.0)
|
||||
artifactory (3.0.15)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.883.0)
|
||||
aws-sdk-core (3.191.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.651.0)
|
||||
aws-sigv4 (~> 1.8)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.77.0)
|
||||
aws-sdk-core (~> 3, >= 3.191.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.143.0)
|
||||
aws-sdk-core (~> 3, >= 3.191.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.8)
|
||||
aws-sigv4 (1.8.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.6.5)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.6.20240107)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (0.109.0)
|
||||
faraday (1.10.3)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
faraday-excon (~> 1.1)
|
||||
faraday-httpclient (~> 1.0)
|
||||
faraday-multipart (~> 1.0)
|
||||
faraday-net_http (~> 1.0)
|
||||
faraday-net_http_persistent (~> 1.0)
|
||||
faraday-patron (~> 1.0)
|
||||
faraday-rack (~> 1.0)
|
||||
faraday-retry (~> 1.0)
|
||||
ruby2_keywords (>= 0.0.4)
|
||||
faraday-cookie_jar (0.0.7)
|
||||
faraday (>= 0.8.0)
|
||||
http-cookie (~> 1.0.0)
|
||||
faraday-em_http (1.0.0)
|
||||
faraday-em_synchrony (1.0.0)
|
||||
faraday-excon (1.1.0)
|
||||
faraday-httpclient (1.0.1)
|
||||
faraday-multipart (1.0.4)
|
||||
multipart-post (~> 2)
|
||||
faraday-net_http (1.0.1)
|
||||
faraday-net_http_persistent (1.2.0)
|
||||
faraday-patron (1.0.0)
|
||||
faraday-rack (1.0.0)
|
||||
faraday-retry (1.0.3)
|
||||
faraday_middleware (1.2.0)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.3.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.54.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
@@ -130,12 +129,12 @@ GEM
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-storage_v1 (0.31.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-cloud-core (1.8.0)
|
||||
google-cloud-core (1.6.1)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (1.6.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)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
@@ -151,46 +150,41 @@ GEM
|
||||
os (>= 0.9, < 2.0)
|
||||
signet (>= 0.16, < 2.a)
|
||||
highline (2.0.3)
|
||||
http-cookie (1.0.8)
|
||||
http-cookie (1.0.5)
|
||||
domain_name (~> 0.5)
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
httpclient (2.8.3)
|
||||
jmespath (1.6.2)
|
||||
json (2.16.0)
|
||||
jwt (2.10.2)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
json (2.7.1)
|
||||
jwt (2.7.1)
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.17.0)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.3.0)
|
||||
optparse (0.8.0)
|
||||
multi_json (1.15.0)
|
||||
multipart-post (2.3.0)
|
||||
nanaimo (0.3.0)
|
||||
naturally (2.2.1)
|
||||
optparse (0.4.0)
|
||||
os (1.1.4)
|
||||
plist (3.7.2)
|
||||
public_suffix (6.0.2)
|
||||
rake (13.3.1)
|
||||
plist (3.7.1)
|
||||
public_suffix (5.0.4)
|
||||
rake (13.1.0)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.1.2)
|
||||
rexml (3.4.4)
|
||||
rouge (3.28.0)
|
||||
rexml (3.2.6)
|
||||
rouge (2.0.7)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.4.1)
|
||||
security (0.1.5)
|
||||
signet (0.21.0)
|
||||
rubyzip (2.3.2)
|
||||
security (0.1.3)
|
||||
signet (0.18.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 4.0)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
multi_json (~> 1.10)
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
sysrandom (1.0.5)
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
@@ -200,32 +194,28 @@ GEM
|
||||
tty-spinner (0.9.3)
|
||||
tty-cursor (~> 0.7)
|
||||
uber (0.1.0)
|
||||
unicode-display_width (2.6.0)
|
||||
unicode-display_width (2.5.0)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.27.0)
|
||||
xcodeproj (1.23.0)
|
||||
CFPropertyList (>= 2.3.3, < 4.0)
|
||||
atomos (~> 0.1.3)
|
||||
claide (>= 1.0.2, < 2.0)
|
||||
colored2 (~> 3.1)
|
||||
nanaimo (~> 0.4.0)
|
||||
rexml (>= 3.3.6, < 4.0)
|
||||
xcpretty (0.4.1)
|
||||
rouge (~> 3.28.0)
|
||||
nanaimo (~> 0.3.0)
|
||||
rexml (~> 3.2.4)
|
||||
xcpretty (0.3.0)
|
||||
rouge (~> 2.0.7)
|
||||
xcpretty-travis-formatter (1.0.1)
|
||||
xcpretty (~> 0.2, >= 0.0.7)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-21
|
||||
arm64-darwin-23
|
||||
arm64-darwin-24
|
||||
arm64-darwin-25
|
||||
x86_64-darwin-19
|
||||
x86_64-darwin-20
|
||||
x86_64-darwin-21
|
||||
x86_64-linux
|
||||
|
||||
DEPENDENCIES
|
||||
fastlane
|
||||
fastlane!
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.22
|
||||
2.3.6
|
||||
|
||||
@@ -10,28 +10,11 @@ struct AccountsBridge: Defaults.Bridge {
|
||||
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 [
|
||||
"id": value.id,
|
||||
"instanceID": value.instanceID ?? "",
|
||||
"name": value.name,
|
||||
"apiURL": sanitizedUrlString,
|
||||
"apiURL": value.urlString,
|
||||
"username": value.username,
|
||||
"password": value.password ?? ""
|
||||
]
|
||||
|
||||
@@ -64,10 +64,6 @@ final class AccountsModel: ObservableObject {
|
||||
)
|
||||
}
|
||||
|
||||
func find(_ id: Account.ID) -> Account? {
|
||||
all.first { $0.id == id }
|
||||
}
|
||||
|
||||
func configureAccount() {
|
||||
if let account = lastUsed ??
|
||||
InstancesModel.shared.lastUsed?.anonymousAccount ??
|
||||
@@ -112,8 +108,8 @@ final class AccountsModel: ObservableObject {
|
||||
Defaults[.accounts].first { $0.id == id }
|
||||
}
|
||||
|
||||
static func add(instance: Instance, id: String? = UUID().uuidString, name: String, username: String, password: String) -> Account {
|
||||
let account = Account(id: id, instanceID: instance.id, name: name, urlString: instance.apiURLString)
|
||||
static func add(instance: Instance, name: String, username: String, password: String) -> Account {
|
||||
let account = Account(instanceID: instance.id, name: name, urlString: instance.apiURLString)
|
||||
Defaults[.accounts].append(account)
|
||||
|
||||
setCredentials(account, username: username, password: password)
|
||||
|
||||
@@ -10,16 +10,14 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
|
||||
let apiURLString: String
|
||||
var frontendURL: String?
|
||||
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.id = id ?? UUID().uuidString
|
||||
self.name = name ?? app.rawValue
|
||||
self.apiURLString = apiURLString
|
||||
self.frontendURL = frontendURL
|
||||
self.proxiesVideos = proxiesVideos
|
||||
self.invidiousCompanion = invidiousCompanion
|
||||
}
|
||||
|
||||
var apiURL: URL! {
|
||||
@@ -70,8 +68,4 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(apiURL)
|
||||
}
|
||||
|
||||
var accounts: [Account] {
|
||||
AccountsModel.shared.all.filter { $0.instanceID == id }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,7 @@ struct InstancesBridge: Defaults.Bridge {
|
||||
"name": value.name,
|
||||
"apiURL": value.apiURLString,
|
||||
"frontendURL": value.frontendURL ?? "",
|
||||
"proxiesVideos": value.proxiesVideos ? "true" : "false",
|
||||
"invidiousCompanion": value.invidiousCompanion ? "true" : "false"
|
||||
"proxiesVideos": value.proxiesVideos ? "true" : "false"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -34,8 +33,7 @@ struct InstancesBridge: Defaults.Bridge {
|
||||
let name = object["name"] ?? ""
|
||||
let frontendURL: String? = object["frontendURL"]!.isEmpty ? nil : object["frontendURL"]
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,23 +42,15 @@ final class InstancesModel: ObservableObject {
|
||||
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(
|
||||
app: app, id: id, name: name, apiURLString: standardizedURL(url)
|
||||
app: app, id: UUID().uuidString, name: name, apiURLString: standardizedURL(url)
|
||||
)
|
||||
Defaults[.instances].append(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) {
|
||||
if let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) {
|
||||
var instance = Defaults[.instances][index]
|
||||
@@ -79,17 +71,6 @@ final class InstancesModel: ObservableObject {
|
||||
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) {
|
||||
let accounts = accounts(instance.id)
|
||||
if let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) {
|
||||
|
||||
@@ -81,7 +81,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
configureTransformer(pathPattern("search/suggestions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [String] in
|
||||
if let suggestions = content.json.dictionaryValue["suggestions"] {
|
||||
return suggestions.arrayValue.map(\.stringValue).map(\.replacingHTMLEntities)
|
||||
return suggestions.arrayValue.map { $0.stringValue.replacingHTMLEntities }
|
||||
}
|
||||
|
||||
return []
|
||||
@@ -123,7 +123,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
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", "releases", "podcasts"].forEach { type in
|
||||
configureTransformer(pathPattern("channels/*/\(type)"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPage in
|
||||
self.extractChannelPage(from: content.json)
|
||||
}
|
||||
@@ -152,10 +152,6 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
FeedModel.shared.onAccountChange()
|
||||
SubscribedChannelsModel.shared.onAccountChange()
|
||||
PlaylistsModel.shared.onAccountChange()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,9 +160,6 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
guard !account.anonymous,
|
||||
(account.token?.isEmpty ?? true) || force
|
||||
else {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -179,9 +172,6 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
title: "Account Error",
|
||||
message: "Remove and add your account again in Settings."
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -222,8 +212,6 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
self.configure()
|
||||
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,27 +247,27 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
var feed: Resource? {
|
||||
resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/feed"))
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/feed"))
|
||||
}
|
||||
|
||||
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 = {}) {
|
||||
resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||
.child(channelID)
|
||||
.request(.post)
|
||||
.onCompletion { _ in onCompletion() }
|
||||
}
|
||||
|
||||
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)
|
||||
.request(.delete)
|
||||
.onCompletion { _ in onCompletion() }
|
||||
@@ -320,11 +308,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
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? {
|
||||
resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/playlists/\(id)"))
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/playlists/\(id)"))
|
||||
}
|
||||
|
||||
func playlistVideos(_ id: String) -> Resource? {
|
||||
@@ -457,9 +445,6 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
urlComponents.scheme = instanceURLComponents.scheme
|
||||
urlComponents.host = instanceURLComponents.host
|
||||
urlComponents.user = instanceURLComponents.user
|
||||
urlComponents.password = instanceURLComponents.password
|
||||
urlComponents.port = instanceURLComponents.port
|
||||
|
||||
guard let url = urlComponents.url else {
|
||||
return nil
|
||||
@@ -510,14 +495,14 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
indexID: indexID,
|
||||
live: json["liveNow"].boolValue,
|
||||
upcoming: json["isUpcoming"].boolValue,
|
||||
short: length <= Video.shortLength && length != 0.0,
|
||||
short: length <= Video.shortLength,
|
||||
publishedAt: publishedAt,
|
||||
likes: json["likeCount"].int,
|
||||
dislikes: json["dislikeCount"].int,
|
||||
keywords: json["keywords"].arrayValue.compactMap(\.string),
|
||||
keywords: json["keywords"].arrayValue.compactMap { $0.string },
|
||||
streams: extractStreams(from: json),
|
||||
related: extractRelated(from: json),
|
||||
chapters: createChapters(from: description, thumbnails: json),
|
||||
chapters: extractChapters(from: description),
|
||||
captions: extractCaptions(from: json)
|
||||
)
|
||||
}
|
||||
@@ -568,30 +553,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] {
|
||||
details["videoThumbnails"].arrayValue.compactMap { json in
|
||||
guard let url = json["url"].url,
|
||||
@@ -602,15 +563,9 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Some instances are not configured properly and return thumbnail links
|
||||
// with an incorrect scheme or a missing port.
|
||||
// some of instances are not configured properly and return thumbnails links
|
||||
// with incorrect 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 {
|
||||
return nil
|
||||
@@ -620,22 +575,6 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
}
|
||||
|
||||
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 func extractChannelPage(from json: JSON, forceNotLast: Bool = false) -> ChannelPage {
|
||||
@@ -666,29 +605,21 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
if json["liveNow"].boolValue {
|
||||
return hls
|
||||
}
|
||||
let videoId = json["videoId"].stringValue
|
||||
|
||||
return extractFormatStreams(from: json["formatStreams"].arrayValue, videoId: videoId) +
|
||||
extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue, videoId: videoId) +
|
||||
return extractFormatStreams(from: json["formatStreams"].arrayValue) +
|
||||
extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue) +
|
||||
hls
|
||||
}
|
||||
|
||||
private func extractFormatStreams(from streams: [JSON], videoId: String?) -> [Stream] {
|
||||
private func extractFormatStreams(from streams: [JSON]) -> [Stream] {
|
||||
streams.compactMap { stream in
|
||||
guard let streamURL = stream["url"].url else {
|
||||
return nil
|
||||
}
|
||||
let finalURL: URL
|
||||
if let videoId, let itag = stream["itag"].string, account.instance.invidiousCompanion {
|
||||
let companionURLString = "\(account.instance.apiURLString)/companion/latest_version?id=\(videoId)&itag=\(itag)"
|
||||
finalURL = URL(string: companionURLString) ?? streamURL
|
||||
} else {
|
||||
finalURL = streamURL
|
||||
}
|
||||
|
||||
return SingleAssetStream(
|
||||
instance: account.instance,
|
||||
avAsset: AVURLAsset(url: finalURL),
|
||||
avAsset: AVURLAsset(url: streamURL),
|
||||
resolution: Stream.Resolution.from(resolution: stream["resolution"].string ?? ""),
|
||||
kind: .stream,
|
||||
encoding: stream["encoding"].string ?? ""
|
||||
@@ -696,94 +627,34 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
}
|
||||
|
||||
func extractXTags(from urlString: String) -> [String: String] {
|
||||
guard let urlComponents = URLComponents(string: urlString),
|
||||
let queryItems = urlComponents.queryItems,
|
||||
let xtagsValue = queryItems.first(where: { $0.name == "xtags" })?.value
|
||||
else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
guard let decoded = xtagsValue.removingPercentEncoding else { return [:] }
|
||||
|
||||
// Parse key-value pairs (format: key1=value1:key2=value2)
|
||||
// Example: "acont=dubbed-auto:lang=en-US"
|
||||
let pairs = decoded.split(separator: ":")
|
||||
var result: [String: String] = [:]
|
||||
for pair in pairs {
|
||||
let parts = pair.split(separator: "=", maxSplits: 1)
|
||||
if parts.count == 2 {
|
||||
result[String(parts[0])] = String(parts[1])
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func extractAdaptiveFormats(from streams: [JSON], videoId: String?) -> [Stream] {
|
||||
let audioTracks = streams
|
||||
private func extractAdaptiveFormats(from streams: [JSON]) -> [Stream] {
|
||||
let audioStreams = streams
|
||||
.filter { $0["type"].stringValue.starts(with: "audio/mp4") }
|
||||
.sorted {
|
||||
$0.dictionaryValue["bitrate"]?.int ?? 0 >
|
||||
$1.dictionaryValue["bitrate"]?.int ?? 0
|
||||
}
|
||||
.compactMap { audioStream -> Stream.AudioTrack? in
|
||||
guard let url = audioStream["url"].url,
|
||||
let audioItag = audioStream["itag"].string
|
||||
else { return nil }
|
||||
|
||||
let finalURL: URL
|
||||
if let videoId, account.instance.invidiousCompanion {
|
||||
let audioCompanionURLString = "\(account.instance.apiURLString)/companion/latest_version?id=\(videoId)&itag=\(audioItag)"
|
||||
finalURL = URL(string: audioCompanionURLString) ?? url
|
||||
} else {
|
||||
finalURL = url
|
||||
}
|
||||
|
||||
let xTags = extractXTags(from: url.absoluteString)
|
||||
|
||||
return Stream.AudioTrack(
|
||||
url: finalURL,
|
||||
content: xTags["acont"],
|
||||
language: xTags["lang"]
|
||||
)
|
||||
}
|
||||
.sorted {
|
||||
/// Always prefer original audio streams over dubbed ones
|
||||
!$0.isDubbed && $1.isDubbed
|
||||
}
|
||||
|
||||
guard !audioTracks.isEmpty else {
|
||||
guard let audioStream = audioStreams.first else {
|
||||
return .init()
|
||||
}
|
||||
|
||||
let videoStreams = streams.filter { $0["type"].stringValue.starts(with: "video/") }
|
||||
|
||||
return videoStreams.compactMap { videoStream in
|
||||
guard let videoAssetURL = videoStream["url"].url,
|
||||
let videoItag = videoStream["itag"].string
|
||||
guard let audioAssetURL = audioStream["url"].url,
|
||||
let videoAssetURL = videoStream["url"].url
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let finalVideoURL: URL
|
||||
|
||||
if let videoId, account.instance.invidiousCompanion {
|
||||
let videoCompanionURLString = "\(account.instance.apiURLString)/companion/latest_version?id=\(videoId)&itag=\(videoItag)"
|
||||
finalVideoURL = URL(string: videoCompanionURLString) ?? videoAssetURL
|
||||
} else {
|
||||
finalVideoURL = videoAssetURL
|
||||
}
|
||||
|
||||
return Stream(
|
||||
instance: account.instance,
|
||||
audioAsset: AVURLAsset(url: audioTracks[0].url),
|
||||
videoAsset: AVURLAsset(url: finalVideoURL),
|
||||
audioAsset: AVURLAsset(url: audioAssetURL),
|
||||
videoAsset: AVURLAsset(url: videoAssetURL),
|
||||
resolution: Stream.Resolution.from(resolution: videoStream["resolution"].stringValue),
|
||||
kind: .adaptive,
|
||||
encoding: videoStream["encoding"].string,
|
||||
videoFormat: videoStream["type"].string,
|
||||
bitrate: videoStream["bitrate"].int,
|
||||
requestRange: videoStream["init"].string ?? videoStream["index"].string,
|
||||
audioTracks: audioTracks
|
||||
videoFormat: videoStream["type"].string
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -820,8 +691,6 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
let author = details["author"]?.string ?? ""
|
||||
let channelId = details["authorId"]?.string ?? UUID().uuidString
|
||||
let authorAvatarURL = details["authorThumbnails"]?.arrayValue.last?.dictionaryValue["url"]?.string ?? ""
|
||||
let htmlContent = details["contentHtml"]?.string ?? ""
|
||||
let decodedContent = decodeHtml(htmlContent)
|
||||
return Comment(
|
||||
id: UUID().uuidString,
|
||||
author: author,
|
||||
@@ -830,25 +699,12 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
pinned: false,
|
||||
hearted: false,
|
||||
likeCount: details["likeCount"]?.int ?? 0,
|
||||
text: decodedContent,
|
||||
text: details["content"]?.string ?? "",
|
||||
repliesPage: details["replies"]?.dictionaryValue["continuation"]?.string,
|
||||
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] {
|
||||
content["captions"].arrayValue.compactMap { details in
|
||||
guard let url = URL(string: details["url"].stringValue, relativeTo: account.url) else { return nil }
|
||||
|
||||
@@ -174,9 +174,6 @@ final class PeerTubeAPI: Service, ObservableObject, VideosAPI {
|
||||
guard !account.anonymous,
|
||||
(account.token?.isEmpty ?? true) || force
|
||||
else {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -189,9 +186,6 @@ final class PeerTubeAPI: Service, ObservableObject, VideosAPI {
|
||||
title: "Account Error",
|
||||
message: "Remove and add your account again in Settings."
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -200,7 +194,6 @@ final class PeerTubeAPI: Service, ObservableObject, VideosAPI {
|
||||
title: "Account Error",
|
||||
message: message ?? "\(response?.response?.statusCode ?? -1) - \(response?.error?.errorDescription ?? "unknown")\nIf this issue persists, try removing and adding your account again in Settings."
|
||||
)
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
|
||||
AF
|
||||
@@ -233,8 +226,6 @@ final class PeerTubeAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
self.configure()
|
||||
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -524,8 +515,7 @@ final class PeerTubeAPI: Service, ObservableObject, VideosAPI {
|
||||
.dictionaryValue["files"]?.arrayValue.first?
|
||||
.dictionaryValue["fileUrl"]?.url
|
||||
{
|
||||
let resolution = Stream.Resolution.predefined(.hd720p30)
|
||||
streams.append(SingleAssetStream(instance: account.instance, avAsset: AVURLAsset(url: fileURL), resolution: resolution, kind: .stream))
|
||||
streams.append(SingleAssetStream(instance: account.instance, avAsset: AVURLAsset(url: fileURL), resolution: .hd720p30, kind: .stream))
|
||||
}
|
||||
|
||||
return streams
|
||||
|
||||
@@ -113,11 +113,8 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
content.json.arrayValue.compactMap { self.extractVideo(from: $0) }
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("comments/*")) { (content: Entity<JSON>?) -> CommentsPage in
|
||||
guard let details = content?.json.dictionaryValue else {
|
||||
return CommentsPage(comments: [], nextPage: nil, disabled: true)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("comments/*")) { (content: Entity<JSON>) -> CommentsPage in
|
||||
let details = content.json.dictionaryValue
|
||||
let comments = details["comments"]?.arrayValue.compactMap { self.extractComment(from: $0) } ?? []
|
||||
let nextPage = details["nextpage"]?.string
|
||||
let disabled = details["disabled"]?.bool ?? false
|
||||
@@ -135,10 +132,6 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
FeedModel.shared.onAccountChange()
|
||||
SubscribedChannelsModel.shared.onAccountChange()
|
||||
PlaylistsModel.shared.onAccountChange()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,9 +146,6 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
let username,
|
||||
let password
|
||||
else {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -164,8 +154,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
method: .post,
|
||||
parameters: ["username": username, "password": password],
|
||||
encoding: JSONEncoding.default
|
||||
)
|
||||
.responseDecodable(of: JSON.self) { [weak self] response in
|
||||
).responseDecodable(of: JSON.self) { [weak self] response in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
@@ -191,14 +180,11 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
self.configure()
|
||||
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
|
||||
case let .failure(error):
|
||||
NavigationModel.shared.presentAlert(
|
||||
title: "Account Error",
|
||||
message: error.localizedDescription
|
||||
)
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -425,7 +411,6 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
if let channel = extractChannel(from: content) {
|
||||
return ContentItem(channel: channel)
|
||||
}
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -502,35 +487,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? {
|
||||
let details = content.dictionaryValue
|
||||
|
||||
@@ -542,10 +498,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
let channelId = details["uploaderUrl"]?.string?.components(separatedBy: "/").last ?? "unknown"
|
||||
|
||||
let qualities = [
|
||||
Thumbnail.Quality.maxresdefault, .high, .medium, .default, .start, .middle, .end
|
||||
]
|
||||
let thumbnails: [Thumbnail] = qualities.compactMap {
|
||||
let thumbnails: [Thumbnail] = Thumbnail.Quality.allCases.compactMap {
|
||||
if let url = buildThumbnailURL(from: content, quality: $0) {
|
||||
return Thumbnail(url: url, quality: $0)
|
||||
}
|
||||
@@ -569,8 +522,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
let formattedDate = dateFormatter.date(from: date)
|
||||
{
|
||||
publishedAt = formattedDate
|
||||
published = ""
|
||||
} else if published.isNil {
|
||||
} else {
|
||||
published = (details["uploadedDate"] ?? details["uploadDate"])?.string ?? ""
|
||||
}
|
||||
|
||||
@@ -605,8 +557,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
dislikes: details["dislikes"]?.int,
|
||||
streams: extractStreams(from: content),
|
||||
related: extractRelated(from: content),
|
||||
chapters: extractChapters(from: content),
|
||||
captions: extractCaptions(from: content)
|
||||
chapters: extractChapters(from: content)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -624,11 +575,10 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
return nil
|
||||
}
|
||||
|
||||
return URL(
|
||||
string: thumbnailURL
|
||||
.absoluteString
|
||||
.replacingOccurrences(of: "hqdefault", with: quality.filename)
|
||||
.replacingOccurrences(of: "maxresdefault", with: quality.filename)
|
||||
return URL(string: thumbnailURL
|
||||
.absoluteString
|
||||
.replacingOccurrences(of: "hqdefault", with: quality.filename)
|
||||
.replacingOccurrences(of: "maxresdefault", with: quality.filename)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -693,77 +643,38 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
streams.append(Stream(instance: account.instance, hlsURL: hlsURL))
|
||||
}
|
||||
|
||||
// Extract all M4A audio streams, sorted by bitrate (highest first)
|
||||
let allAudioStreams = content
|
||||
let audioStreams = content
|
||||
.dictionaryValue["audioStreams"]?
|
||||
.arrayValue
|
||||
.filter { $0.dictionaryValue["format"]?.string == "M4A" }
|
||||
.filter { stream in
|
||||
let type = stream.dictionaryValue["audioTrackType"]?.string
|
||||
return type == nil || type == "ORIGINAL"
|
||||
}
|
||||
.sorted {
|
||||
$0.dictionaryValue["bitrate"]?.int ?? 0 >
|
||||
$1.dictionaryValue["bitrate"]?.int ?? 0
|
||||
} ?? []
|
||||
|
||||
// Group audio streams by track type and language, keeping highest bitrate for each
|
||||
var audioTracksByType = [String: JSON]()
|
||||
for audioStream in allAudioStreams {
|
||||
let trackType = audioStream.dictionaryValue["audioTrackType"]?.string
|
||||
let trackLocale = audioStream.dictionaryValue["audioTrackLocale"]?.string
|
||||
|
||||
// Create a unique key for this audio track combination
|
||||
let key = "\(trackType ?? "ORIGINAL")_\(trackLocale ?? "")"
|
||||
|
||||
// Only keep the first (highest bitrate) stream for each unique track type/locale combination
|
||||
if audioTracksByType[key] == nil {
|
||||
audioTracksByType[key] = audioStream
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to Stream.AudioTrack array
|
||||
let audioTracks: [Stream.AudioTrack] = audioTracksByType.values.compactMap { audioStream in
|
||||
guard let url = audioStream.dictionaryValue["url"]?.url else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let trackType = audioStream.dictionaryValue["audioTrackType"]?.string
|
||||
let trackLocale = audioStream.dictionaryValue["audioTrackLocale"]?.string
|
||||
|
||||
return Stream.AudioTrack(
|
||||
url: url,
|
||||
content: trackType,
|
||||
language: trackLocale
|
||||
)
|
||||
}
|
||||
.sorted { track1, track2 in
|
||||
// Sort: ORIGINAL first, then DUBBED, then others
|
||||
if track1.content == "ORIGINAL", track2.content != "ORIGINAL" {
|
||||
return true
|
||||
}
|
||||
if track1.content != "ORIGINAL", track2.content == "ORIGINAL" {
|
||||
return false
|
||||
}
|
||||
// If both are same type, sort by language
|
||||
return (track1.language ?? "") < (track2.language ?? "")
|
||||
}
|
||||
|
||||
// Fallback to first audio stream if no tracks were extracted
|
||||
guard !audioTracks.isEmpty else {
|
||||
guard let audioStream = audioStreams.first else {
|
||||
return streams
|
||||
}
|
||||
|
||||
let videoStreams = content.dictionaryValue["videoStreams"]?.arrayValue ?? []
|
||||
|
||||
for videoStream in videoStreams {
|
||||
videoStreams.forEach { videoStream in
|
||||
let videoCodec = videoStream.dictionaryValue["codec"]?.string ?? ""
|
||||
if Self.disallowedVideoCodecs.contains(where: videoCodec.contains) {
|
||||
continue
|
||||
return
|
||||
}
|
||||
|
||||
guard let videoAssetUrl = videoStream.dictionaryValue["url"]?.url else {
|
||||
continue
|
||||
guard let audioAssetUrl = audioStream.dictionaryValue["url"]?.url,
|
||||
let videoAssetUrl = videoStream.dictionaryValue["url"]?.url
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
// Use the first (ORIGINAL) audio track as default
|
||||
let defaultAudioAsset = AVURLAsset(url: audioTracks[0].url)
|
||||
let audioAsset = AVURLAsset(url: audioAssetUrl)
|
||||
let videoAsset = AVURLAsset(url: videoAssetUrl)
|
||||
|
||||
let videoOnly = videoStream.dictionaryValue["videoOnly"]?.bool ?? true
|
||||
@@ -772,33 +683,16 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
let fps = qualityComponents.count > 1 ? Int(qualityComponents[1]) : 30
|
||||
let resolution = Stream.Resolution.from(resolution: quality, fps: fps)
|
||||
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 {
|
||||
streams.append(
|
||||
Stream(
|
||||
instance: account.instance,
|
||||
audioAsset: defaultAudioAsset,
|
||||
audioAsset: audioAsset,
|
||||
videoAsset: videoAsset,
|
||||
resolution: resolution,
|
||||
kind: .adaptive,
|
||||
videoFormat: videoFormat,
|
||||
bitrate: bitrate,
|
||||
requestRange: requestRange,
|
||||
audioTracks: audioTracks
|
||||
videoFormat: videoFormat
|
||||
)
|
||||
)
|
||||
} else {
|
||||
@@ -829,23 +723,15 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
let commentorUrl = details["commentorUrl"]?.string
|
||||
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(
|
||||
id: commentId,
|
||||
id: details["commentId"]?.string ?? UUID().uuidString,
|
||||
author: author,
|
||||
authorAvatarURL: details["thumbnail"]?.string ?? "",
|
||||
time: details["commentedTime"]?.string ?? "",
|
||||
pinned: details["pinned"]?.bool ?? false,
|
||||
hearted: details["hearted"]?.bool ?? false,
|
||||
likeCount: details["likeCount"]?.int ?? 0,
|
||||
text: commentText,
|
||||
text: extractCommentText(from: details["commentText"]?.stringValue),
|
||||
repliesPage: details["repliesPage"]?.string,
|
||||
channel: Channel(app: .piped, id: channelId, name: author)
|
||||
)
|
||||
@@ -874,24 +760,6 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
}
|
||||
|
||||
private func extractCaptions(from content: JSON) -> [Captions] {
|
||||
content["subtitles"].arrayValue.compactMap { details in
|
||||
guard let url = details["url"].url,
|
||||
let code = details["code"].string,
|
||||
let label = details["name"].string,
|
||||
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
else { return nil }
|
||||
|
||||
components.queryItems = components.queryItems?.map { item in
|
||||
item.name == "fmt" ? URLQueryItem(name: "fmt", value: "srt") : item
|
||||
}
|
||||
|
||||
guard let newUrl = components.url else { return nil }
|
||||
|
||||
return Captions(label: label, code: code, url: newUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private func contentItemsDictionary(from content: JSON) -> JSON {
|
||||
if let key = Self.contentItemsKeys.first(where: { content.dictionaryValue.keys.contains($0) }),
|
||||
let items = content.dictionaryValue[key]
|
||||
|
||||
@@ -66,7 +66,7 @@ protocol VideosAPI {
|
||||
failureHandler: ((RequestError) -> 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?
|
||||
}
|
||||
@@ -108,20 +108,15 @@ extension VideosAPI {
|
||||
.onFailure { failureHandler?($0) }
|
||||
}
|
||||
|
||||
func shareURL(_ item: ContentItem, frontendURLString: String? = nil, time: CMTime? = nil) -> URL? {
|
||||
var urlComponents: URLComponents?
|
||||
if let frontendURLString,
|
||||
let frontendURL = URL(string: frontendURLString)
|
||||
{
|
||||
urlComponents = URLComponents(url: frontendURL, resolvingAgainstBaseURL: false)
|
||||
} else if let instanceComponents = account?.instance?.urlComponents {
|
||||
urlComponents = instanceComponents
|
||||
}
|
||||
|
||||
guard var urlComponents else {
|
||||
func shareURL(_ item: ContentItem, frontendHost: String? = nil, time: CMTime? = nil) -> URL? {
|
||||
guard let frontendHost = frontendHost ?? account?.instance?.frontendHost,
|
||||
var urlComponents = account?.instance?.urlComponents
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
urlComponents.host = frontendHost
|
||||
|
||||
var queryItems = [URLQueryItem]()
|
||||
|
||||
switch item.contentType {
|
||||
@@ -152,94 +147,58 @@ extension VideosAPI {
|
||||
/*
|
||||
The following chapter patterns are covered:
|
||||
|
||||
1) "start - end - title" / "start - end: Title" / "start - end title"
|
||||
2) "start - title" / "start: title" / "start title" / "[start] - title" / "[start]: title" / "[start] title"
|
||||
3) "index. title - start" / "index. title start"
|
||||
4) "title: (start)"
|
||||
5) "(start) title"
|
||||
start - end - title / start - end: Title / start - end title
|
||||
start - title / start: title / start title / [start] - title / [start]: title / [start] title
|
||||
index. title - start / index. title start
|
||||
title: (start)
|
||||
|
||||
These represent:
|
||||
|
||||
- "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.
|
||||
The order is important!
|
||||
*/
|
||||
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|$)"
|
||||
"(?<=\\n|^)(?<title>.+?):\\s*\\((?<start>(?:[0-9]+:){1,2}[0-9]+)\\)(?=\\n|$)"
|
||||
]
|
||||
|
||||
let extractChaptersGroup = DispatchGroup()
|
||||
var capturedChapters: [Int: [Chapter]] = [:]
|
||||
let lock = NSLock()
|
||||
for pattern in patterns {
|
||||
guard let chaptersRegularExpression = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { continue }
|
||||
let chapterLines = chaptersRegularExpression.matches(in: description, range: NSRange(description.startIndex..., in: description))
|
||||
|
||||
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 startRange = line.range(withName: "start")
|
||||
if !chapterLines.isEmpty {
|
||||
return chapterLines.compactMap { line in
|
||||
let titleRange = line.range(withName: "title")
|
||||
let startRange = line.range(withName: "start")
|
||||
guard let titleSubstringRange = Range(titleRange, in: description),
|
||||
let startSubstringRange = Range(startRange, in: description)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let titleCapture = String(description[titleSubstringRange]).trimmingCharacters(in: .whitespaces)
|
||||
let startCapture = String(description[startSubstringRange])
|
||||
let startComponents = startCapture.components(separatedBy: ":")
|
||||
guard startComponents.count <= 3 else { return nil }
|
||||
|
||||
guard let titleSubstringRange = Range(titleRange, in: description),
|
||||
let startSubstringRange = Range(startRange, in: description)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
var hours: Double?
|
||||
var minutes: Double?
|
||||
var seconds: Double?
|
||||
|
||||
let titleCapture = String(description[titleSubstringRange]).trimmingCharacters(in: .whitespaces)
|
||||
let startCapture = String(description[startSubstringRange])
|
||||
let startComponents = startCapture.components(separatedBy: ":")
|
||||
guard startComponents.count <= 3 else { return nil }
|
||||
|
||||
var hours: Double?
|
||||
var minutes: Double?
|
||||
var seconds: Double?
|
||||
|
||||
if startComponents.count == 3 {
|
||||
hours = Double(startComponents[0])
|
||||
minutes = Double(startComponents[1])
|
||||
seconds = Double(startComponents[2])
|
||||
} else if startComponents.count == 2 {
|
||||
minutes = Double(startComponents[0])
|
||||
seconds = Double(startComponents[1])
|
||||
}
|
||||
|
||||
guard var startSeconds = seconds else { return nil }
|
||||
|
||||
startSeconds += (minutes ?? 0) * 60
|
||||
startSeconds += (hours ?? 0) * 60 * 60
|
||||
|
||||
return Chapter(title: titleCapture, start: startSeconds)
|
||||
if startComponents.count == 3 {
|
||||
hours = Double(startComponents[0])
|
||||
minutes = Double(startComponents[1])
|
||||
seconds = Double(startComponents[2])
|
||||
} else if startComponents.count == 2 {
|
||||
minutes = Double(startComponents[0])
|
||||
seconds = Double(startComponents[1])
|
||||
}
|
||||
|
||||
if !extractedChapters.isEmpty {
|
||||
lock.lock()
|
||||
capturedChapters[index] = extractedChapters
|
||||
lock.unlock()
|
||||
}
|
||||
guard var startSeconds = seconds else { return nil }
|
||||
|
||||
startSeconds += (minutes ?? 0) * 60
|
||||
startSeconds += (hours ?? 0) * 60 * 60
|
||||
|
||||
return .init(title: titleCapture, start: startSeconds)
|
||||
}
|
||||
extractChaptersGroup.leave()
|
||||
}
|
||||
}
|
||||
|
||||
extractChaptersGroup.wait()
|
||||
|
||||
// Now we sort the keys of the capturedChapters dictionary.
|
||||
// These keys correspond to the priority of each pattern.
|
||||
let sortedKeys = Array(capturedChapters.keys).sorted(by: <)
|
||||
|
||||
// Return first non-empty result in the order of patterns
|
||||
for key in sortedKeys {
|
||||
if let chapters = capturedChapters[key], !chapters.isEmpty {
|
||||
return chapters
|
||||
}
|
||||
}
|
||||
return []
|
||||
|
||||
@@ -95,7 +95,7 @@ enum VideosApp: String, CaseIterable {
|
||||
}
|
||||
|
||||
var allowsDisablingVidoesProxying: Bool {
|
||||
self == .invidious || self == .piped
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var supportsOpeningVideosByID: Bool {
|
||||
|
||||
@@ -13,7 +13,6 @@ struct ChannelPlaylistsCacheModel: CacheModel {
|
||||
var storage = try? Storage<String, JSON>(
|
||||
diskConfig: Self.diskConfig,
|
||||
memoryConfig: Self.memoryConfig,
|
||||
fileManager: FileManager.default,
|
||||
transformer: BaseCacheModel.jsonTransformer
|
||||
)
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ struct ChannelsCacheModel: CacheModel {
|
||||
let storage = try? Storage<String, JSON>(
|
||||
diskConfig: Self.diskConfig,
|
||||
memoryConfig: Self.memoryConfig,
|
||||
fileManager: FileManager.default,
|
||||
transformer: BaseCacheModel.jsonTransformer
|
||||
)
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ struct FeedCacheModel: CacheModel {
|
||||
let storage = try? Storage<String, JSON>(
|
||||
diskConfig: Self.diskConfig,
|
||||
memoryConfig: Self.memoryConfig,
|
||||
fileManager: FileManager.default,
|
||||
transformer: BaseCacheModel.jsonTransformer
|
||||
)
|
||||
|
||||
@@ -23,7 +22,7 @@ struct FeedCacheModel: CacheModel {
|
||||
let date = iso8601DateFormatter.string(from: Date())
|
||||
logger.info("caching feed \(account.feedCacheKey) -- \(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(videosObject, forKey: account.feedCacheKey)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ struct PlaylistsCacheModel: CacheModel {
|
||||
let storage = try? Storage<String, JSON>(
|
||||
diskConfig: Self.diskConfig,
|
||||
memoryConfig: Self.memoryConfig,
|
||||
fileManager: FileManager.default,
|
||||
transformer: BaseCacheModel.jsonTransformer
|
||||
)
|
||||
|
||||
@@ -22,7 +21,7 @@ struct PlaylistsCacheModel: CacheModel {
|
||||
let date = iso8601DateFormatter.string(from: Date())
|
||||
logger.info("caching \(playlistCacheKey(account)) -- \(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(playlistsObject, forKey: playlistCacheKey(account))
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ final class SubscribedChannelsModel: ObservableObject, CacheModel {
|
||||
let storage = try? Storage<String, JSON>(
|
||||
diskConfig: SubscribedChannelsModel.diskConfig,
|
||||
memoryConfig: SubscribedChannelsModel.memoryConfig,
|
||||
fileManager: FileManager.default,
|
||||
transformer: BaseCacheModel.jsonTransformer
|
||||
)
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ struct VideosCacheModel: CacheModel {
|
||||
let storage = try? Storage<String, JSON>(
|
||||
diskConfig: Self.diskConfig,
|
||||
memoryConfig: Self.memoryConfig,
|
||||
fileManager: FileManager.default,
|
||||
transformer: BaseCacheModel.jsonTransformer
|
||||
)
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ struct Channel: Identifiable, Hashable {
|
||||
"subscriptionsText": subscriptionsText as Any,
|
||||
"totalViews": totalViews as Any,
|
||||
"verified": verified as Any,
|
||||
"videos": videos.map(\.json.object)
|
||||
"videos": videos.map { $0.json.object }
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ struct ChannelPlaylist: Identifiable {
|
||||
"title": title,
|
||||
"thumbnailURL": thumbnailURL?.absoluteString ?? "",
|
||||
"channel": channel?.json.object ?? "",
|
||||
"videos": videos.map(\.json.object),
|
||||
"videos": videos.map { $0.json.object },
|
||||
"videosCount": String(videosCount ?? 0)
|
||||
]
|
||||
}
|
||||
|
||||
@@ -35,22 +35,26 @@ final class CommentsModel: ObservableObject {
|
||||
|
||||
func load(page: String? = nil) {
|
||||
guard let video = player.currentVideo else { return }
|
||||
guard firstPage || nextPageAvailable else { return }
|
||||
|
||||
if !firstPage && !nextPageAvailable {
|
||||
return
|
||||
}
|
||||
|
||||
firstPage = page.isNil || page!.isEmpty
|
||||
|
||||
player
|
||||
.playerAPI(video)?
|
||||
.comments(video.videoID, page: page)?
|
||||
.load()
|
||||
.onSuccess { [weak self] response in
|
||||
guard let self else { return }
|
||||
if let commentsPage: CommentsPage = response.typedContent() {
|
||||
self.all += commentsPage.comments
|
||||
self.nextPage = commentsPage.nextPage
|
||||
self.disabled = commentsPage.disabled
|
||||
if let page: CommentsPage = response.typedContent() {
|
||||
self?.all += page.comments
|
||||
self?.nextPage = page.nextPage
|
||||
self?.disabled = page.disabled
|
||||
}
|
||||
}
|
||||
.onFailure { [weak self] _ in
|
||||
self?.disabled = true
|
||||
.onFailure { [weak self] requestError in
|
||||
self?.disabled = !requestError.json.dictionaryValue["error"].isNil
|
||||
}
|
||||
.onCompletion { [weak self] _ in
|
||||
self?.loaded = true
|
||||
|
||||
@@ -274,7 +274,7 @@ extension Country {
|
||||
|
||||
private static func filteredCountries(_ predicate: (String) -> Bool) -> [Country] {
|
||||
Country.allCases
|
||||
.map(\.name)
|
||||
.map { $0.name }
|
||||
.filter(predicate)
|
||||
.compactMap { string in Country.allCases.first { $0.name == string } }
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ struct FavoritesModel {
|
||||
}
|
||||
|
||||
func add(_ item: FavoriteItem) {
|
||||
if contains(item) { return }
|
||||
all.append(item)
|
||||
}
|
||||
|
||||
@@ -123,12 +122,4 @@ struct FavoritesModel {
|
||||
func widgetSettings(_ item: FavoriteItem) -> WidgetSettings {
|
||||
widgetsSettings.first { $0.id == item.widgetSettingsKey } ?? WidgetSettings(id: item.widgetSettingsKey)
|
||||
}
|
||||
|
||||
func updateWidgetSettings(_ settings: WidgetSettings) {
|
||||
if let index = widgetsSettings.firstIndex(where: { $0.id == settings.id }) {
|
||||
widgetsSettings[index] = settings
|
||||
} else {
|
||||
widgetsSettings.append(settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ final class FeedModel: ObservableObject, CacheModel {
|
||||
backgroundContext.perform { [weak self] in
|
||||
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 unwatchedCount = max(0, feed.count - watched.count)
|
||||
|
||||
@@ -235,7 +235,7 @@ final class FeedModel: ObservableObject, CacheModel {
|
||||
let watches = watchFetchRequestResult(videos, context: backgroundContext)
|
||||
let watchesIDs = watches.map(\.videoID)
|
||||
let unwatched = videos.filter { video in
|
||||
if FeatureFlags.hideShortsEnabled, Defaults[.hideShorts], video.short {
|
||||
if Defaults[.hideShorts], video.short {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
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 time = time ?? backend.currentTime
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class AdvancedSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"showPlayNowInBackendContextMenu": Defaults[.showPlayNowInBackendContextMenu],
|
||||
"videoLoadingRetryCount": Defaults[.videoLoadingRetryCount],
|
||||
"showMPVPlaybackStats": Defaults[.showMPVPlaybackStats],
|
||||
"mpvEnableLogging": Defaults[.mpvEnableLogging],
|
||||
"mpvCacheSecs": Defaults[.mpvCacheSecs],
|
||||
"mpvCachePauseWait": Defaults[.mpvCachePauseWait],
|
||||
"mpvCachePauseInital": Defaults[.mpvCachePauseInital],
|
||||
"mpvDeinterlace": Defaults[.mpvDeinterlace],
|
||||
"mpvHWdec": Defaults[.mpvHWdec],
|
||||
"mpvDemuxerLavfProbeInfo": Defaults[.mpvDemuxerLavfProbeInfo],
|
||||
"mpvSetRefreshToContentFPS": Defaults[.mpvSetRefreshToContentFPS],
|
||||
"mpvInitialAudioSync": Defaults[.mpvInitialAudioSync],
|
||||
"showCacheStatus": Defaults[.showCacheStatus],
|
||||
"feedCacheSize": Defaults[.feedCacheSize]
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class BrowsingSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"showHome": Defaults[.showHome],
|
||||
"showOpenActionsInHome": Defaults[.showOpenActionsInHome],
|
||||
"showQueueInHome": Defaults[.showQueueInHome],
|
||||
"showFavoritesInHome": Defaults[.showFavoritesInHome],
|
||||
"favorites": Defaults[.favorites].compactMap { jsonFromString(FavoriteItem.bridge.serialize($0)) },
|
||||
"widgetsSettings": Defaults[.widgetsSettings].compactMap { widgetSettingsJSON($0) },
|
||||
"startupSection": Defaults[.startupSection].rawValue,
|
||||
"showSearchSuggestions": Defaults[.showSearchSuggestions],
|
||||
"visibleSections": Defaults[.visibleSections].compactMap(\.rawValue),
|
||||
"showOpenActionsToolbarItem": Defaults[.showOpenActionsToolbarItem],
|
||||
"accountPickerDisplaysAnonymousAccounts": Defaults[.accountPickerDisplaysAnonymousAccounts],
|
||||
"showUnwatchedFeedBadges": Defaults[.showUnwatchedFeedBadges],
|
||||
"expandChannelDescription": Defaults[.expandChannelDescription],
|
||||
"keepChannelsWithUnwatchedFeedOnTop": Defaults[.keepChannelsWithUnwatchedFeedOnTop],
|
||||
"showChannelAvatarInChannelsLists": Defaults[.showChannelAvatarInChannelsLists],
|
||||
"showChannelAvatarInVideosListing": Defaults[.showChannelAvatarInVideosListing],
|
||||
"playerButtonSingleTapGesture": Defaults[.playerButtonSingleTapGesture].rawValue,
|
||||
"playerButtonDoubleTapGesture": Defaults[.playerButtonDoubleTapGesture].rawValue,
|
||||
"playerButtonShowsControlButtonsWhenMinimized": Defaults[.playerButtonShowsControlButtonsWhenMinimized],
|
||||
"playerButtonIsExpanded": Defaults[.playerButtonIsExpanded],
|
||||
"playerBarMaxWidth": Defaults[.playerBarMaxWidth],
|
||||
"channelOnThumbnail": Defaults[.channelOnThumbnail],
|
||||
"timeOnThumbnail": Defaults[.timeOnThumbnail],
|
||||
"roundedThumbnails": Defaults[.roundedThumbnails],
|
||||
"thumbnailsQuality": Defaults[.thumbnailsQuality].rawValue
|
||||
]
|
||||
}
|
||||
|
||||
override var platformJSON: JSON {
|
||||
var export = JSON()
|
||||
|
||||
#if os(iOS)
|
||||
export["showDocuments"].bool = Defaults[.showDocuments]
|
||||
export["lockPortraitWhenBrowsing"].bool = Defaults[.lockPortraitWhenBrowsing]
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
export["accountPickerDisplaysUsername"].bool = Defaults[.accountPickerDisplaysUsername]
|
||||
#endif
|
||||
|
||||
return export
|
||||
}
|
||||
|
||||
private func widgetSettingsJSON(_ settings: WidgetSettings) -> JSON {
|
||||
var json = JSON()
|
||||
json.dictionaryObject = WidgetSettingsBridge().serialize(settings)
|
||||
return json
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class ConstrolsSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"avPlayerUsesSystemControls": Defaults[.avPlayerUsesSystemControls],
|
||||
"fullscreenPlayerGestureEnabled": Defaults[.fullscreenPlayerGestureEnabled],
|
||||
"horizontalPlayerGestureEnabled": Defaults[.horizontalPlayerGestureEnabled],
|
||||
"seekGestureSensitivity": Defaults[.seekGestureSensitivity],
|
||||
"seekGestureSpeed": Defaults[.seekGestureSpeed],
|
||||
"playerControlsLayout": Defaults[.playerControlsLayout].rawValue,
|
||||
"fullScreenPlayerControlsLayout": Defaults[.fullScreenPlayerControlsLayout].rawValue,
|
||||
"playerControlsBackgroundOpacity": Defaults[.playerControlsBackgroundOpacity],
|
||||
"systemControlsCommands": Defaults[.systemControlsCommands].rawValue,
|
||||
"buttonBackwardSeekDuration": Defaults[.buttonBackwardSeekDuration],
|
||||
"buttonForwardSeekDuration": Defaults[.buttonForwardSeekDuration],
|
||||
"gestureBackwardSeekDuration": Defaults[.gestureBackwardSeekDuration],
|
||||
"gestureForwardSeekDuration": Defaults[.gestureForwardSeekDuration],
|
||||
"systemControlsSeekDuration": Defaults[.systemControlsSeekDuration],
|
||||
"playerControlsSettingsEnabled": Defaults[.playerControlsSettingsEnabled],
|
||||
"playerControlsCloseEnabled": Defaults[.playerControlsCloseEnabled],
|
||||
"playerControlsRestartEnabled": Defaults[.playerControlsRestartEnabled],
|
||||
"playerControlsAdvanceToNextEnabled": Defaults[.playerControlsAdvanceToNextEnabled],
|
||||
"playerControlsPlaybackModeEnabled": Defaults[.playerControlsPlaybackModeEnabled],
|
||||
"playerControlsMusicModeEnabled": Defaults[.playerControlsMusicModeEnabled],
|
||||
"playerActionsButtonLabelStyle": Defaults[.playerActionsButtonLabelStyle].rawValue,
|
||||
"actionButtonShareEnabled": Defaults[.actionButtonShareEnabled],
|
||||
"actionButtonAddToPlaylistEnabled": Defaults[.actionButtonAddToPlaylistEnabled],
|
||||
"actionButtonSubscribeEnabled": Defaults[.actionButtonSubscribeEnabled],
|
||||
"actionButtonSettingsEnabled": Defaults[.actionButtonSettingsEnabled],
|
||||
"actionButtonHideEnabled": Defaults[.actionButtonHideEnabled],
|
||||
"actionButtonCloseEnabled": Defaults[.actionButtonCloseEnabled],
|
||||
"actionButtonFullScreenEnabled": Defaults[.actionButtonFullScreenEnabled],
|
||||
"actionButtonPipEnabled": Defaults[.actionButtonPipEnabled],
|
||||
"actionButtonLockOrientationEnabled": Defaults[.actionButtonLockOrientationEnabled],
|
||||
"actionButtonRestartEnabled": Defaults[.actionButtonRestartEnabled],
|
||||
"actionButtonAdvanceToNextItemEnabled": Defaults[.actionButtonAdvanceToNextItemEnabled],
|
||||
"actionButtonMusicModeEnabled": Defaults[.actionButtonMusicModeEnabled]
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class HistorySettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"saveRecents": Defaults[.saveRecents],
|
||||
"saveHistory": Defaults[.saveHistory],
|
||||
"showRecents": Defaults[.showRecents],
|
||||
"limitRecents": Defaults[.limitRecents],
|
||||
"limitRecentsAmount": Defaults[.limitRecentsAmount],
|
||||
"showWatchingProgress": Defaults[.showWatchingProgress],
|
||||
"saveLastPlayed": Defaults[.saveLastPlayed],
|
||||
|
||||
"watchedVideoPlayNowBehavior": Defaults[.watchedVideoPlayNowBehavior].rawValue,
|
||||
"watchedThreshold": Defaults[.watchedThreshold],
|
||||
"resetWatchedStatusOnPlaying": Defaults[.resetWatchedStatusOnPlaying],
|
||||
|
||||
"watchedVideoStyle": Defaults[.watchedVideoStyle].rawValue,
|
||||
"watchedVideoBadgeColor": Defaults[.watchedVideoBadgeColor].rawValue,
|
||||
"showToggleWatchedStatusButton": Defaults[.showToggleWatchedStatusButton]
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class LocationsSettingsGroupExporter: SettingsGroupExporter {
|
||||
var includePublicInstances = true
|
||||
var includeInstances = true
|
||||
var includeAccounts = true
|
||||
var includeAccountsUnencryptedPasswords = false
|
||||
|
||||
init(includePublicInstances: Bool = true, includeInstances: Bool = true, includeAccounts: Bool = true, includeAccountsUnencryptedPasswords: Bool = false) {
|
||||
self.includePublicInstances = includePublicInstances
|
||||
self.includeInstances = includeInstances
|
||||
self.includeAccounts = includeAccounts
|
||||
self.includeAccountsUnencryptedPasswords = includeAccountsUnencryptedPasswords
|
||||
}
|
||||
|
||||
override var globalJSON: JSON {
|
||||
var json = JSON()
|
||||
|
||||
if includePublicInstances {
|
||||
json["instancesManifest"].string = Defaults[.instancesManifest]
|
||||
json["countryOfPublicInstances"].string = Defaults[.countryOfPublicInstances] ?? ""
|
||||
}
|
||||
|
||||
if includeInstances {
|
||||
json["instances"].arrayObject = Defaults[.instances].compactMap { instanceJSON($0) }
|
||||
}
|
||||
|
||||
if includeAccounts {
|
||||
json["accounts"].arrayObject = Defaults[.accounts].compactMap { account in
|
||||
var account = account
|
||||
let (username, password) = AccountsModel.getCredentials(account)
|
||||
account.username = username ?? ""
|
||||
if includeAccountsUnencryptedPasswords {
|
||||
account.password = password ?? ""
|
||||
}
|
||||
|
||||
return accountJSON(account).dictionaryObject
|
||||
}
|
||||
}
|
||||
|
||||
return json
|
||||
}
|
||||
|
||||
private func instanceJSON(_ instance: Instance) -> JSON {
|
||||
var json = JSON()
|
||||
json.dictionaryObject = InstancesBridge().serialize(instance)
|
||||
return json
|
||||
}
|
||||
|
||||
private func accountJSON(_ account: Account) -> JSON {
|
||||
var json = JSON()
|
||||
json.dictionaryObject = AccountsBridge().serialize(account)
|
||||
return json
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class OtherDataSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"lastAccountID": Defaults[.lastAccountID] ?? "",
|
||||
"lastInstanceID": Defaults[.lastInstanceID] ?? "",
|
||||
|
||||
"playerRate": Defaults[.playerRate],
|
||||
|
||||
"trendingCategory": Defaults[.trendingCategory].rawValue,
|
||||
"trendingCountry": Defaults[.trendingCountry].rawValue,
|
||||
|
||||
"subscriptionsViewPage": Defaults[.subscriptionsViewPage].rawValue,
|
||||
"subscriptionsListingStyle": Defaults[.subscriptionsListingStyle].rawValue,
|
||||
"popularListingStyle": Defaults[.popularListingStyle].rawValue,
|
||||
"trendingListingStyle": Defaults[.trendingListingStyle].rawValue,
|
||||
"playlistListingStyle": Defaults[.playlistListingStyle].rawValue,
|
||||
"channelPlaylistListingStyle": Defaults[.channelPlaylistListingStyle].rawValue,
|
||||
"searchListingStyle": Defaults[.searchListingStyle].rawValue,
|
||||
|
||||
"hideShorts": Defaults[.hideShorts],
|
||||
"hideWatched": Defaults[.hideWatched]
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class PlayerSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"playerInstanceID": Defaults[.playerInstanceID] ?? "",
|
||||
"pauseOnHidingPlayer": Defaults[.pauseOnHidingPlayer],
|
||||
"closeVideoOnEOF": Defaults[.closeVideoOnEOF],
|
||||
"exitFullscreenOnEOF": Defaults[.exitFullscreenOnEOF],
|
||||
"expandVideoDescription": Defaults[.expandVideoDescription],
|
||||
"collapsedLinesDescription": Defaults[.collapsedLinesDescription],
|
||||
"showChapters": Defaults[.showChapters],
|
||||
"showChapterThumbnails": Defaults[.showChapterThumbnails],
|
||||
"showChapterThumbnailsOnlyWhenDifferent": Defaults[.showChapterThumbnailsOnlyWhenDifferent],
|
||||
"expandChapters": Defaults[.expandChapters],
|
||||
"showRelated": Defaults[.showRelated],
|
||||
"showInspector": Defaults[.showInspector].rawValue,
|
||||
"playerSidebar": Defaults[.playerSidebar].rawValue,
|
||||
"showKeywords": Defaults[.showKeywords],
|
||||
"enableReturnYouTubeDislike": Defaults[.enableReturnYouTubeDislike],
|
||||
"closePiPOnNavigation": Defaults[.closePiPOnNavigation],
|
||||
"closePiPOnOpeningPlayer": Defaults[.closePiPOnOpeningPlayer],
|
||||
"closePlayerOnOpeningPiP": Defaults[.closePlayerOnOpeningPiP],
|
||||
"captionsAutoShow": Defaults[.captionsAutoShow],
|
||||
"captionsDefaultLanguageCode": Defaults[.captionsDefaultLanguageCode],
|
||||
"captionsFallbackLanguageCode": Defaults[.captionsFallbackLanguageCode],
|
||||
"captionsFontScaleSize": Defaults[.captionsFontScaleSize],
|
||||
"captionsFontColor": Defaults[.captionsFontColor]
|
||||
]
|
||||
}
|
||||
|
||||
override var platformJSON: JSON {
|
||||
var export = JSON()
|
||||
|
||||
#if !os(macOS)
|
||||
export["pauseOnEnteringBackground"].bool = Defaults[.pauseOnEnteringBackground]
|
||||
#endif
|
||||
|
||||
export["showComments"].bool = Defaults[.showComments]
|
||||
|
||||
#if !os(tvOS)
|
||||
export["showScrollToTopInComments"].bool = Defaults[.showScrollToTopInComments]
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
export["isOrientationLocked"].bool = Defaults[.isOrientationLocked]
|
||||
export["enterFullscreenInLandscape"].bool = Defaults[.enterFullscreenInLandscape]
|
||||
export["rotateToLandscapeOnEnterFullScreen"].string = Defaults[.rotateToLandscapeOnEnterFullScreen].rawValue
|
||||
#endif
|
||||
|
||||
return export
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class QualitySettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"batteryCellularProfile": Defaults[.batteryCellularProfile],
|
||||
"batteryNonCellularProfile": Defaults[.batteryNonCellularProfile],
|
||||
"chargingCellularProfile": Defaults[.chargingCellularProfile],
|
||||
"chargingNonCellularProfile": Defaults[.chargingNonCellularProfile],
|
||||
"forceAVPlayerForLiveStreams": Defaults[.forceAVPlayerForLiveStreams],
|
||||
"qualityProfiles": Defaults[.qualityProfiles].compactMap { qualityProfileJSON($0) }
|
||||
]
|
||||
}
|
||||
|
||||
func qualityProfileJSON(_ profile: QualityProfile) -> JSON {
|
||||
var json = JSON()
|
||||
json.dictionaryObject = QualityProfileBridge().serialize(profile)
|
||||
return json
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class RecentlyOpenedExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"recentlyOpened": Defaults[.recentlyOpened].compactMap { recentItemJSON($0) }
|
||||
]
|
||||
}
|
||||
|
||||
private func recentItemJSON(_ recentItem: RecentItem) -> JSON {
|
||||
var json = JSON()
|
||||
json.dictionaryObject = RecentItemBridge().serialize(recentItem)
|
||||
return json
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftyJSON
|
||||
|
||||
class SettingsGroupExporter { // swiftlint:disable:this final_class
|
||||
var globalJSON: JSON {
|
||||
[]
|
||||
}
|
||||
|
||||
var platformJSON: JSON {
|
||||
[]
|
||||
}
|
||||
|
||||
var exportJSON: JSON {
|
||||
var json = globalJSON
|
||||
|
||||
if !platformJSON.isEmpty {
|
||||
try? json.merge(with: platformJSON)
|
||||
}
|
||||
|
||||
return json
|
||||
}
|
||||
|
||||
func jsonFromString(_ string: String?) -> JSON? {
|
||||
if let data = string?.data(using: .utf8, allowLossyConversion: false),
|
||||
let json = try? JSON(data: data)
|
||||
{
|
||||
return json
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class SponsorBlockSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"sponsorBlockInstance": Defaults[.sponsorBlockInstance],
|
||||
"sponsorBlockCategories": Array(Defaults[.sponsorBlockCategories]),
|
||||
"sponsorBlockColors": Defaults[.sponsorBlockColors],
|
||||
"sponsorBlockShowTimeWithSkipsRemoved": Defaults[.sponsorBlockShowTimeWithSkipsRemoved],
|
||||
"sponsorBlockShowCategoriesInTimeline": Defaults[.sponsorBlockShowCategoriesInTimeline],
|
||||
"sponsorBlockShowNoticeAfterSkip": Defaults[.sponsorBlockShowNoticeAfterSkip]
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import SwiftyJSON
|
||||
|
||||
final class ImportExportSettingsModel: ObservableObject {
|
||||
static let shared = ImportExportSettingsModel()
|
||||
|
||||
static var exportFile: URL {
|
||||
YatteeApp.settingsExportDirectory
|
||||
.appendingPathComponent("Yattee Settings from \(Constants.deviceName).\(settingsExtension)")
|
||||
}
|
||||
|
||||
static var settingsExtension: String {
|
||||
"yatteesettings"
|
||||
}
|
||||
|
||||
enum ExportGroup: String, Identifiable, CaseIterable {
|
||||
case browsingSettings
|
||||
case playerSettings
|
||||
case controlsSettings
|
||||
case qualitySettings
|
||||
case historySettings
|
||||
case sponsorBlockSettings
|
||||
case advancedSettings
|
||||
|
||||
case locationsSettings
|
||||
case instances
|
||||
case accounts
|
||||
case accountsUnencryptedPasswords
|
||||
|
||||
case recentlyOpened
|
||||
case otherData
|
||||
|
||||
static var settingsGroups: [Self] {
|
||||
[.browsingSettings, .playerSettings, .controlsSettings, .qualitySettings, .historySettings, .sponsorBlockSettings, .advancedSettings]
|
||||
}
|
||||
|
||||
static var locationsGroups: [Self] {
|
||||
[.locationsSettings, .instances, .accounts, .accountsUnencryptedPasswords]
|
||||
}
|
||||
|
||||
static var otherGroups: [Self] {
|
||||
[.recentlyOpened, .otherData]
|
||||
}
|
||||
|
||||
var id: RawValue {
|
||||
rawValue
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .browsingSettings:
|
||||
return "Browsing"
|
||||
case .playerSettings:
|
||||
return "Player"
|
||||
case .controlsSettings:
|
||||
return "Controls"
|
||||
case .qualitySettings:
|
||||
return "Quality"
|
||||
case .historySettings:
|
||||
return "History"
|
||||
case .sponsorBlockSettings:
|
||||
return "SponsorBlock"
|
||||
case .locationsSettings:
|
||||
return "Public Locations"
|
||||
case .instances:
|
||||
return "Custom Locations"
|
||||
case .accounts:
|
||||
return "Accounts"
|
||||
case .accountsUnencryptedPasswords:
|
||||
return "Accounts passwords (unencrypted)"
|
||||
case .advancedSettings:
|
||||
return "Advanced"
|
||||
case .recentlyOpened:
|
||||
return "Recents"
|
||||
case .otherData:
|
||||
return "Other data"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published var selectedExportGroups = Set<ExportGroup>()
|
||||
static var defaultExportGroups = Set<ExportGroup>([
|
||||
.browsingSettings,
|
||||
.playerSettings,
|
||||
.controlsSettings,
|
||||
.qualitySettings,
|
||||
.historySettings,
|
||||
.sponsorBlockSettings,
|
||||
.locationsSettings,
|
||||
.instances,
|
||||
.accounts,
|
||||
.advancedSettings
|
||||
])
|
||||
|
||||
@Published var isExportInProgress = false
|
||||
|
||||
private var navigation = NavigationModel.shared
|
||||
private var settings = SettingsModel.shared
|
||||
|
||||
func toggleExportGroupSelection(_ group: ExportGroup) {
|
||||
if isGroupSelected(group) {
|
||||
selectedExportGroups.remove(group)
|
||||
} else {
|
||||
selectedExportGroups.insert(group)
|
||||
}
|
||||
|
||||
removeNotEnabledSelectedGroups()
|
||||
}
|
||||
|
||||
func reset() {
|
||||
isExportInProgress = false
|
||||
selectedExportGroups = Self.defaultExportGroups
|
||||
}
|
||||
|
||||
func reset(_ model: ImportSettingsFileModel? = nil) {
|
||||
reset()
|
||||
|
||||
guard let model else { return }
|
||||
|
||||
selectedExportGroups = selectedExportGroups.filter { model.isGroupIncludedInFile($0) }
|
||||
}
|
||||
|
||||
func exportAction() {
|
||||
DispatchQueue.global(qos: .background).async { [weak self] in
|
||||
var writingOptions: JSONSerialization.WritingOptions = []
|
||||
#if DEBUG
|
||||
writingOptions.insert(.prettyPrinted)
|
||||
writingOptions.insert(.sortedKeys)
|
||||
#endif
|
||||
try? self?.jsonForExport?.rawString(options: writingOptions)?.write(to: Self.exportFile, atomically: true, encoding: String.Encoding.utf8)
|
||||
#if os(macOS)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.isExportInProgress = false
|
||||
}
|
||||
NSWorkspace.shared.selectFile(Self.exportFile.path, inFileViewerRootedAtPath: YatteeApp.settingsExportDirectory.path)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var jsonForExport: JSON? {
|
||||
[
|
||||
"metadata": metadataJSON,
|
||||
"browsingSettings": selectedExportGroups.contains(.browsingSettings) ? BrowsingSettingsGroupExporter().exportJSON : JSON(),
|
||||
"playerSettings": selectedExportGroups.contains(.playerSettings) ? PlayerSettingsGroupExporter().exportJSON : JSON(),
|
||||
"controlsSettings": selectedExportGroups.contains(.controlsSettings) ? ConstrolsSettingsGroupExporter().exportJSON : JSON(),
|
||||
"qualitySettings": selectedExportGroups.contains(.qualitySettings) ? QualitySettingsGroupExporter().exportJSON : JSON(),
|
||||
"historySettings": selectedExportGroups.contains(.historySettings) ? HistorySettingsGroupExporter().exportJSON : JSON(),
|
||||
"sponsorBlockSettings": selectedExportGroups.contains(.sponsorBlockSettings) ? SponsorBlockSettingsGroupExporter().exportJSON : JSON(),
|
||||
"locationsSettings": LocationsSettingsGroupExporter(
|
||||
includePublicInstances: isGroupSelected(.locationsSettings),
|
||||
includeInstances: isGroupSelected(.instances),
|
||||
includeAccounts: isGroupSelected(.accounts),
|
||||
includeAccountsUnencryptedPasswords: isGroupSelected(.accountsUnencryptedPasswords)
|
||||
).exportJSON,
|
||||
"advancedSettings": selectedExportGroups.contains(.advancedSettings) ? AdvancedSettingsGroupExporter().exportJSON : JSON(),
|
||||
"recentlyOpened": selectedExportGroups.contains(.recentlyOpened) ? RecentlyOpenedExporter().exportJSON : JSON(),
|
||||
"otherData": selectedExportGroups.contains(.otherData) ? OtherDataSettingsGroupExporter().exportJSON : JSON()
|
||||
]
|
||||
}
|
||||
|
||||
private var metadataJSON: JSON {
|
||||
[
|
||||
"build": YatteeApp.build,
|
||||
"timestamp": "\(Date().timeIntervalSince1970)",
|
||||
"platform": Constants.platform
|
||||
]
|
||||
}
|
||||
|
||||
func isGroupSelected(_ group: ExportGroup) -> Bool {
|
||||
selectedExportGroups.contains(group)
|
||||
}
|
||||
|
||||
func isGroupEnabled(_ group: ExportGroup) -> Bool {
|
||||
switch group {
|
||||
case .accounts:
|
||||
return selectedExportGroups.contains(.instances)
|
||||
case .accountsUnencryptedPasswords:
|
||||
return selectedExportGroups.contains(.instances) && selectedExportGroups.contains(.accounts)
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func removeNotEnabledSelectedGroups() {
|
||||
selectedExportGroups = selectedExportGroups.filter { isGroupEnabled($0) }
|
||||
}
|
||||
|
||||
var isExportAvailable: Bool {
|
||||
!selectedExportGroups.isEmpty && !isExportInProgress
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
import SwiftyJSON
|
||||
|
||||
final class ImportSettingsFileModel: ObservableObject {
|
||||
static let shared = ImportSettingsFileModel()
|
||||
|
||||
var locationsSettingsGroupImporter: LocationsSettingsGroupImporter? {
|
||||
if let locationsSettings = json.dictionaryValue["locationsSettings"] {
|
||||
return LocationsSettingsGroupImporter(
|
||||
json: locationsSettings,
|
||||
includePublicLocations: importExportModel.isGroupEnabled(.locationsSettings),
|
||||
includedInstancesIDs: sheetViewModel.selectedInstances,
|
||||
includedAccountsIDs: sheetViewModel.selectedAccounts,
|
||||
includedAccountsPasswords: sheetViewModel.importableAccountsPasswords
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var importExportModel = ImportExportSettingsModel.shared
|
||||
var sheetViewModel = ImportSettingsSheetViewModel.shared
|
||||
|
||||
var loadTask: URLSessionTask?
|
||||
|
||||
func isGroupIncludedInFile(_ group: ImportExportSettingsModel.ExportGroup) -> Bool {
|
||||
switch group {
|
||||
case .locationsSettings:
|
||||
return isPublicInstancesSettingsGroupInFile || instancesOrAccountsInFile
|
||||
default:
|
||||
return !groupJSON(group).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
var isPublicInstancesSettingsGroupInFile: Bool {
|
||||
guard let dict = groupJSON(.locationsSettings).dictionary else { return false }
|
||||
|
||||
return dict.keys.contains("instancesManifest") || dict.keys.contains("countryOfPublicInstances")
|
||||
}
|
||||
|
||||
var instancesOrAccountsInFile: Bool {
|
||||
guard let dict = groupJSON(.locationsSettings).dictionary else { return false }
|
||||
|
||||
return (dict.keys.contains("instances") && !(dict["instances"]?.arrayValue.isEmpty ?? true)) ||
|
||||
(dict.keys.contains("accounts") && !(dict["accounts"]?.arrayValue.isEmpty ?? true))
|
||||
}
|
||||
|
||||
func groupJSON(_ group: ImportExportSettingsModel.ExportGroup) -> JSON {
|
||||
json.dictionaryValue[group.rawValue] ?? .init()
|
||||
}
|
||||
|
||||
func performImport() {
|
||||
if importExportModel.isGroupSelected(.browsingSettings), isGroupIncludedInFile(.browsingSettings) {
|
||||
BrowsingSettingsGroupImporter(json: groupJSON(.browsingSettings)).performImport()
|
||||
}
|
||||
|
||||
if importExportModel.isGroupSelected(.playerSettings), isGroupIncludedInFile(.playerSettings) {
|
||||
PlayerSettingsGroupImporter(json: groupJSON(.playerSettings)).performImport()
|
||||
}
|
||||
|
||||
if importExportModel.isGroupSelected(.controlsSettings), isGroupIncludedInFile(.controlsSettings) {
|
||||
ConstrolsSettingsGroupImporter(json: groupJSON(.controlsSettings)).performImport()
|
||||
}
|
||||
|
||||
if importExportModel.isGroupSelected(.qualitySettings), isGroupIncludedInFile(.qualitySettings) {
|
||||
QualitySettingsGroupImporter(json: groupJSON(.qualitySettings)).performImport()
|
||||
}
|
||||
|
||||
if importExportModel.isGroupSelected(.historySettings), isGroupIncludedInFile(.historySettings) {
|
||||
HistorySettingsGroupImporter(json: groupJSON(.historySettings)).performImport()
|
||||
}
|
||||
|
||||
if importExportModel.isGroupSelected(.sponsorBlockSettings), isGroupIncludedInFile(.sponsorBlockSettings) {
|
||||
SponsorBlockSettingsGroupImporter(json: groupJSON(.sponsorBlockSettings)).performImport()
|
||||
}
|
||||
|
||||
locationsSettingsGroupImporter?.performImport()
|
||||
|
||||
if importExportModel.isGroupSelected(.advancedSettings), isGroupIncludedInFile(.advancedSettings) {
|
||||
AdvancedSettingsGroupImporter(json: groupJSON(.advancedSettings)).performImport()
|
||||
}
|
||||
|
||||
if importExportModel.isGroupSelected(.recentlyOpened), isGroupIncludedInFile(.recentlyOpened) {
|
||||
RecentlyOpenedImporter(json: groupJSON(.recentlyOpened)).performImport()
|
||||
}
|
||||
|
||||
if importExportModel.isGroupSelected(.otherData), isGroupIncludedInFile(.otherData) {
|
||||
OtherDataSettingsGroupImporter(json: groupJSON(.otherData)).performImport()
|
||||
}
|
||||
}
|
||||
|
||||
@Published var json = JSON()
|
||||
|
||||
func loadData(_ url: URL) {
|
||||
json = JSON()
|
||||
loadTask?.cancel()
|
||||
|
||||
loadTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
|
||||
guard let data else { return }
|
||||
|
||||
if let json = try? JSON(data: data) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.json = json
|
||||
|
||||
self.sheetViewModel.reset(locationsSettingsGroupImporter)
|
||||
self.importExportModel.reset(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
loadTask?.resume()
|
||||
}
|
||||
|
||||
func filename(_ url: URL) -> String {
|
||||
String(url.lastPathComponent.dropLast(ImportExportSettingsModel.settingsExtension.count + 1))
|
||||
}
|
||||
|
||||
var metadataBuild: String? {
|
||||
if let build = json.dictionaryValue["metadata"]?.dictionaryValue["build"]?.string {
|
||||
return build
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var metadataPlatform: String? {
|
||||
if let platform = json.dictionaryValue["metadata"]?.dictionaryValue["platform"]?.string {
|
||||
return platform
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var metadataDate: String? {
|
||||
if let timestamp = json.dictionaryValue["metadata"]?.dictionaryValue["timestamp"]?.doubleValue {
|
||||
let date = Date(timeIntervalSince1970: timestamp)
|
||||
return dateFormatter.string(from: date)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var dateFormatter: DateFormatter {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .long
|
||||
formatter.timeStyle = .medium
|
||||
|
||||
return formatter
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct AdvancedSettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let showPlayNowInBackendContextMenu = json["showPlayNowInBackendContextMenu"].bool {
|
||||
Defaults[.showPlayNowInBackendContextMenu] = showPlayNowInBackendContextMenu
|
||||
}
|
||||
|
||||
if let videoLoadingRetryCount = json["videoLoadingRetryCount"].int {
|
||||
Defaults[.videoLoadingRetryCount] = videoLoadingRetryCount
|
||||
}
|
||||
|
||||
if let showMPVPlaybackStats = json["showMPVPlaybackStats"].bool {
|
||||
Defaults[.showMPVPlaybackStats] = showMPVPlaybackStats
|
||||
}
|
||||
|
||||
if let mpvEnableLogging = json["mpvEnableLogging"].bool {
|
||||
Defaults[.mpvEnableLogging] = mpvEnableLogging
|
||||
}
|
||||
|
||||
if let mpvCacheSecs = json["mpvCacheSecs"].string {
|
||||
Defaults[.mpvCacheSecs] = mpvCacheSecs
|
||||
}
|
||||
|
||||
if let mpvCachePauseWait = json["mpvCachePauseWait"].string {
|
||||
Defaults[.mpvCachePauseWait] = mpvCachePauseWait
|
||||
}
|
||||
|
||||
if let mpvCachePauseInital = json["mpvCachePauseInital"].bool {
|
||||
Defaults[.mpvCachePauseInital] = mpvCachePauseInital
|
||||
}
|
||||
|
||||
if let mpvDeinterlace = json["mpvDeinterlace"].bool {
|
||||
Defaults[.mpvDeinterlace] = mpvDeinterlace
|
||||
}
|
||||
|
||||
if let mpvHWdec = json["mpvHWdec"].string {
|
||||
Defaults[.mpvHWdec] = mpvHWdec
|
||||
}
|
||||
|
||||
if let mpvDemuxerLavfProbeInfo = json["mpvDemuxerLavfProbeInfo"].string {
|
||||
Defaults[.mpvDemuxerLavfProbeInfo] = mpvDemuxerLavfProbeInfo
|
||||
}
|
||||
|
||||
if let mpvSetRefreshToContentFPS = json["mpvSetRefreshToContentFPS"].bool {
|
||||
Defaults[.mpvSetRefreshToContentFPS] = mpvSetRefreshToContentFPS
|
||||
}
|
||||
|
||||
if let mpvInitialAudioSync = json["mpvInitialAudioSync"].bool {
|
||||
Defaults[.mpvInitialAudioSync] = mpvInitialAudioSync
|
||||
}
|
||||
|
||||
if let showCacheStatus = json["showCacheStatus"].bool {
|
||||
Defaults[.showCacheStatus] = showCacheStatus
|
||||
}
|
||||
|
||||
if let feedCacheSize = json["feedCacheSize"].string {
|
||||
Defaults[.feedCacheSize] = feedCacheSize
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct BrowsingSettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let showHome = json["showHome"].bool {
|
||||
Defaults[.showHome] = showHome
|
||||
}
|
||||
|
||||
if let showOpenActionsInHome = json["showOpenActionsInHome"].bool {
|
||||
Defaults[.showOpenActionsInHome] = showOpenActionsInHome
|
||||
}
|
||||
|
||||
if let showQueueInHome = json["showQueueInHome"].bool {
|
||||
Defaults[.showQueueInHome] = showQueueInHome
|
||||
}
|
||||
|
||||
if let showFavoritesInHome = json["showFavoritesInHome"].bool {
|
||||
Defaults[.showFavoritesInHome] = showFavoritesInHome
|
||||
}
|
||||
|
||||
if let favorites = json["favorites"].array {
|
||||
for favoriteJSON in favorites {
|
||||
if let jsonString = favoriteJSON.rawString(options: []),
|
||||
let item = FavoriteItem.bridge.deserialize(jsonString)
|
||||
{
|
||||
FavoritesModel.shared.add(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let widgetsFavorites = json["widgetsSettings"].array {
|
||||
for widgetJSON in widgetsFavorites {
|
||||
let dict = widgetJSON.dictionaryValue.mapValues { json in json.stringValue }
|
||||
if let item = WidgetSettingsBridge().deserialize(dict) {
|
||||
FavoritesModel.shared.updateWidgetSettings(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let startupSectionString = json["startupSection"].string,
|
||||
let startupSection = StartupSection(rawValue: startupSectionString)
|
||||
{
|
||||
Defaults[.startupSection] = startupSection
|
||||
}
|
||||
|
||||
if let showSearchSuggestions = json["showSearchSuggestions"].bool {
|
||||
Defaults[.showSearchSuggestions] = showSearchSuggestions
|
||||
}
|
||||
|
||||
if let visibleSections = json["visibleSections"].array {
|
||||
let sections = visibleSections.compactMap { visibleSectionJSON in
|
||||
if let visibleSectionString = visibleSectionJSON.rawString(options: []),
|
||||
let section = VisibleSection(rawValue: visibleSectionString)
|
||||
{
|
||||
return section
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Defaults[.visibleSections] = Set(sections)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
if let showOpenActionsToolbarItem = json["showOpenActionsToolbarItem"].bool {
|
||||
Defaults[.showOpenActionsToolbarItem] = showOpenActionsToolbarItem
|
||||
}
|
||||
|
||||
if let lockPortraitWhenBrowsing = json["lockPortraitWhenBrowsing"].bool {
|
||||
Defaults[.lockPortraitWhenBrowsing] = lockPortraitWhenBrowsing
|
||||
}
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
if let accountPickerDisplaysUsername = json["accountPickerDisplaysUsername"].bool {
|
||||
Defaults[.accountPickerDisplaysUsername] = accountPickerDisplaysUsername
|
||||
}
|
||||
#endif
|
||||
|
||||
if let accountPickerDisplaysAnonymousAccounts = json["accountPickerDisplaysAnonymousAccounts"].bool {
|
||||
Defaults[.accountPickerDisplaysAnonymousAccounts] = accountPickerDisplaysAnonymousAccounts
|
||||
}
|
||||
|
||||
if let showUnwatchedFeedBadges = json["showUnwatchedFeedBadges"].bool {
|
||||
Defaults[.showUnwatchedFeedBadges] = showUnwatchedFeedBadges
|
||||
}
|
||||
|
||||
if let expandChannelDescription = json["expandChannelDescription"].bool {
|
||||
Defaults[.expandChannelDescription] = expandChannelDescription
|
||||
}
|
||||
|
||||
if let keepChannelsWithUnwatchedFeedOnTop = json["keepChannelsWithUnwatchedFeedOnTop"].bool {
|
||||
Defaults[.keepChannelsWithUnwatchedFeedOnTop] = keepChannelsWithUnwatchedFeedOnTop
|
||||
}
|
||||
|
||||
if let showChannelAvatarInChannelsLists = json["showChannelAvatarInChannelsLists"].bool {
|
||||
Defaults[.showChannelAvatarInChannelsLists] = showChannelAvatarInChannelsLists
|
||||
}
|
||||
|
||||
if let showChannelAvatarInVideosListing = json["showChannelAvatarInVideosListing"].bool {
|
||||
Defaults[.showChannelAvatarInVideosListing] = showChannelAvatarInVideosListing
|
||||
}
|
||||
|
||||
if let playerButtonSingleTapGestureString = json["playerButtonSingleTapGesture"].string,
|
||||
let playerButtonSingleTapGesture = PlayerTapGestureAction(rawValue: playerButtonSingleTapGestureString)
|
||||
{
|
||||
Defaults[.playerButtonSingleTapGesture] = playerButtonSingleTapGesture
|
||||
}
|
||||
|
||||
if let playerButtonDoubleTapGestureString = json["playerButtonDoubleTapGesture"].string,
|
||||
let playerButtonDoubleTapGesture = PlayerTapGestureAction(rawValue: playerButtonDoubleTapGestureString)
|
||||
{
|
||||
Defaults[.playerButtonDoubleTapGesture] = playerButtonDoubleTapGesture
|
||||
}
|
||||
|
||||
if let playerButtonShowsControlButtonsWhenMinimized = json["playerButtonShowsControlButtonsWhenMinimized"].bool {
|
||||
Defaults[.playerButtonShowsControlButtonsWhenMinimized] = playerButtonShowsControlButtonsWhenMinimized
|
||||
}
|
||||
|
||||
if let playerButtonIsExpanded = json["playerButtonIsExpanded"].bool {
|
||||
Defaults[.playerButtonIsExpanded] = playerButtonIsExpanded
|
||||
}
|
||||
|
||||
if let playerBarMaxWidth = json["playerBarMaxWidth"].string {
|
||||
Defaults[.playerBarMaxWidth] = playerBarMaxWidth
|
||||
}
|
||||
|
||||
if let channelOnThumbnail = json["channelOnThumbnail"].bool {
|
||||
Defaults[.channelOnThumbnail] = channelOnThumbnail
|
||||
}
|
||||
|
||||
if let timeOnThumbnail = json["timeOnThumbnail"].bool {
|
||||
Defaults[.timeOnThumbnail] = timeOnThumbnail
|
||||
}
|
||||
|
||||
if let roundedThumbnails = json["roundedThumbnails"].bool {
|
||||
Defaults[.roundedThumbnails] = roundedThumbnails
|
||||
}
|
||||
|
||||
if let thumbnailsQualityString = json["thumbnailsQuality"].string,
|
||||
let thumbnailsQuality = ThumbnailsQuality(rawValue: thumbnailsQualityString)
|
||||
{
|
||||
Defaults[.thumbnailsQuality] = thumbnailsQuality
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct ConstrolsSettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let avPlayerUsesSystemControls = json["avPlayerUsesSystemControls"].bool {
|
||||
Defaults[.avPlayerUsesSystemControls] = avPlayerUsesSystemControls
|
||||
}
|
||||
|
||||
if let fullscreenPlayerGestureEnabled = json["fullscreenPlayerGestureEnabled"].bool {
|
||||
Defaults[.fullscreenPlayerGestureEnabled] = fullscreenPlayerGestureEnabled
|
||||
}
|
||||
|
||||
if let horizontalPlayerGestureEnabled = json["horizontalPlayerGestureEnabled"].bool {
|
||||
Defaults[.horizontalPlayerGestureEnabled] = horizontalPlayerGestureEnabled
|
||||
}
|
||||
|
||||
if let seekGestureSensitivity = json["seekGestureSensitivity"].double {
|
||||
Defaults[.seekGestureSensitivity] = seekGestureSensitivity
|
||||
}
|
||||
|
||||
if let seekGestureSpeed = json["seekGestureSpeed"].double {
|
||||
Defaults[.seekGestureSpeed] = seekGestureSpeed
|
||||
}
|
||||
|
||||
if let playerControlsLayoutString = json["playerControlsLayout"].string,
|
||||
let playerControlsLayout = PlayerControlsLayout(rawValue: playerControlsLayoutString)
|
||||
{
|
||||
Defaults[.playerControlsLayout] = playerControlsLayout
|
||||
}
|
||||
|
||||
if let fullScreenPlayerControlsLayoutString = json["fullScreenPlayerControlsLayout"].string,
|
||||
let fullScreenPlayerControlsLayout = PlayerControlsLayout(rawValue: fullScreenPlayerControlsLayoutString)
|
||||
{
|
||||
Defaults[.fullScreenPlayerControlsLayout] = fullScreenPlayerControlsLayout
|
||||
}
|
||||
|
||||
if let playerControlsBackgroundOpacity = json["playerControlsBackgroundOpacity"].double {
|
||||
Defaults[.playerControlsBackgroundOpacity] = playerControlsBackgroundOpacity
|
||||
}
|
||||
|
||||
if let systemControlsCommandsString = json["systemControlsCommands"].string,
|
||||
let systemControlsCommands = SystemControlsCommands(rawValue: systemControlsCommandsString)
|
||||
{
|
||||
Defaults[.systemControlsCommands] = systemControlsCommands
|
||||
}
|
||||
|
||||
if let buttonBackwardSeekDuration = json["buttonBackwardSeekDuration"].string {
|
||||
Defaults[.buttonBackwardSeekDuration] = buttonBackwardSeekDuration
|
||||
}
|
||||
|
||||
if let buttonForwardSeekDuration = json["buttonForwardSeekDuration"].string {
|
||||
Defaults[.buttonForwardSeekDuration] = buttonForwardSeekDuration
|
||||
}
|
||||
|
||||
if let gestureBackwardSeekDuration = json["gestureBackwardSeekDuration"].string {
|
||||
Defaults[.gestureBackwardSeekDuration] = gestureBackwardSeekDuration
|
||||
}
|
||||
|
||||
if let gestureForwardSeekDuration = json["gestureForwardSeekDuration"].string {
|
||||
Defaults[.gestureForwardSeekDuration] = gestureForwardSeekDuration
|
||||
}
|
||||
|
||||
if let systemControlsSeekDuration = json["systemControlsSeekDuration"].string {
|
||||
Defaults[.systemControlsSeekDuration] = systemControlsSeekDuration
|
||||
}
|
||||
|
||||
if let playerControlsSettingsEnabled = json["playerControlsSettingsEnabled"].bool {
|
||||
Defaults[.playerControlsSettingsEnabled] = playerControlsSettingsEnabled
|
||||
}
|
||||
|
||||
if let playerControlsCloseEnabled = json["playerControlsCloseEnabled"].bool {
|
||||
Defaults[.playerControlsCloseEnabled] = playerControlsCloseEnabled
|
||||
}
|
||||
|
||||
if let playerControlsRestartEnabled = json["playerControlsRestartEnabled"].bool {
|
||||
Defaults[.playerControlsRestartEnabled] = playerControlsRestartEnabled
|
||||
}
|
||||
|
||||
if let playerControlsAdvanceToNextEnabled = json["playerControlsAdvanceToNextEnabled"].bool {
|
||||
Defaults[.playerControlsAdvanceToNextEnabled] = playerControlsAdvanceToNextEnabled
|
||||
}
|
||||
|
||||
if let playerControlsPlaybackModeEnabled = json["playerControlsPlaybackModeEnabled"].bool {
|
||||
Defaults[.playerControlsPlaybackModeEnabled] = playerControlsPlaybackModeEnabled
|
||||
}
|
||||
|
||||
if let playerControlsMusicModeEnabled = json["playerControlsMusicModeEnabled"].bool {
|
||||
Defaults[.playerControlsMusicModeEnabled] = playerControlsMusicModeEnabled
|
||||
}
|
||||
|
||||
if let playerActionsButtonLabelStyleString = json["playerActionsButtonLabelStyle"].string,
|
||||
let playerActionsButtonLabelStyle = ButtonLabelStyle(rawValue: playerActionsButtonLabelStyleString)
|
||||
{
|
||||
Defaults[.playerActionsButtonLabelStyle] = playerActionsButtonLabelStyle
|
||||
}
|
||||
|
||||
if let actionButtonShareEnabled = json["actionButtonShareEnabled"].bool {
|
||||
Defaults[.actionButtonShareEnabled] = actionButtonShareEnabled
|
||||
}
|
||||
|
||||
if let actionButtonAddToPlaylistEnabled = json["actionButtonAddToPlaylistEnabled"].bool {
|
||||
Defaults[.actionButtonAddToPlaylistEnabled] = actionButtonAddToPlaylistEnabled
|
||||
}
|
||||
|
||||
if let actionButtonSubscribeEnabled = json["actionButtonSubscribeEnabled"].bool {
|
||||
Defaults[.actionButtonSubscribeEnabled] = actionButtonSubscribeEnabled
|
||||
}
|
||||
|
||||
if let actionButtonSettingsEnabled = json["actionButtonSettingsEnabled"].bool {
|
||||
Defaults[.actionButtonSettingsEnabled] = actionButtonSettingsEnabled
|
||||
}
|
||||
|
||||
if let actionButtonHideEnabled = json["actionButtonHideEnabled"].bool {
|
||||
Defaults[.actionButtonHideEnabled] = actionButtonHideEnabled
|
||||
}
|
||||
|
||||
if let actionButtonCloseEnabled = json["actionButtonCloseEnabled"].bool {
|
||||
Defaults[.actionButtonCloseEnabled] = actionButtonCloseEnabled
|
||||
}
|
||||
|
||||
if let actionButtonFullScreenEnabled = json["actionButtonFullScreenEnabled"].bool {
|
||||
Defaults[.actionButtonFullScreenEnabled] = actionButtonFullScreenEnabled
|
||||
}
|
||||
|
||||
if let actionButtonPipEnabled = json["actionButtonPipEnabled"].bool {
|
||||
Defaults[.actionButtonPipEnabled] = actionButtonPipEnabled
|
||||
}
|
||||
|
||||
if let actionButtonLockOrientationEnabled = json["actionButtonLockOrientationEnabled"].bool {
|
||||
Defaults[.actionButtonLockOrientationEnabled] = actionButtonLockOrientationEnabled
|
||||
}
|
||||
|
||||
if let actionButtonRestartEnabled = json["actionButtonRestartEnabled"].bool {
|
||||
Defaults[.actionButtonRestartEnabled] = actionButtonRestartEnabled
|
||||
}
|
||||
|
||||
if let actionButtonAdvanceToNextItemEnabled = json["actionButtonAdvanceToNextItemEnabled"].bool {
|
||||
Defaults[.actionButtonAdvanceToNextItemEnabled] = actionButtonAdvanceToNextItemEnabled
|
||||
}
|
||||
|
||||
if let actionButtonMusicModeEnabled = json["actionButtonMusicModeEnabled"].bool {
|
||||
Defaults[.actionButtonMusicModeEnabled] = actionButtonMusicModeEnabled
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct HistorySettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let saveRecents = json["saveRecents"].bool {
|
||||
Defaults[.saveRecents] = saveRecents
|
||||
}
|
||||
|
||||
if let saveHistory = json["saveHistory"].bool {
|
||||
Defaults[.saveHistory] = saveHistory
|
||||
}
|
||||
|
||||
if let showRecents = json["showRecents"].bool {
|
||||
Defaults[.showRecents] = showRecents
|
||||
}
|
||||
|
||||
if let limitRecents = json["limitRecents"].bool {
|
||||
Defaults[.limitRecents] = limitRecents
|
||||
}
|
||||
|
||||
if let limitRecentsAmount = json["limitRecentsAmount"].int {
|
||||
Defaults[.limitRecentsAmount] = limitRecentsAmount
|
||||
}
|
||||
|
||||
if let showWatchingProgress = json["showWatchingProgress"].bool {
|
||||
Defaults[.showWatchingProgress] = showWatchingProgress
|
||||
}
|
||||
|
||||
if let saveLastPlayed = json["saveLastPlayed"].bool {
|
||||
Defaults[.saveLastPlayed] = saveLastPlayed
|
||||
}
|
||||
|
||||
if let watchedVideoPlayNowBehaviorString = json["watchedVideoPlayNowBehavior"].string,
|
||||
let watchedVideoPlayNowBehavior = WatchedVideoPlayNowBehavior(rawValue: watchedVideoPlayNowBehaviorString)
|
||||
{
|
||||
Defaults[.watchedVideoPlayNowBehavior] = watchedVideoPlayNowBehavior
|
||||
}
|
||||
|
||||
if let watchedThreshold = json["watchedThreshold"].int {
|
||||
Defaults[.watchedThreshold] = watchedThreshold
|
||||
}
|
||||
|
||||
if let resetWatchedStatusOnPlaying = json["resetWatchedStatusOnPlaying"].bool {
|
||||
Defaults[.resetWatchedStatusOnPlaying] = resetWatchedStatusOnPlaying
|
||||
}
|
||||
|
||||
if let watchedVideoStyleString = json["watchedVideoStyle"].string,
|
||||
let watchedVideoStyle = WatchedVideoStyle(rawValue: watchedVideoStyleString)
|
||||
{
|
||||
Defaults[.watchedVideoStyle] = watchedVideoStyle
|
||||
}
|
||||
|
||||
if let watchedVideoBadgeColorString = json["watchedVideoBadgeColor"].string,
|
||||
let watchedVideoBadgeColor = WatchedVideoBadgeColor(rawValue: watchedVideoBadgeColorString)
|
||||
{
|
||||
Defaults[.watchedVideoBadgeColor] = watchedVideoBadgeColor
|
||||
}
|
||||
|
||||
if let showToggleWatchedStatusButton = json["showToggleWatchedStatusButton"].bool {
|
||||
Defaults[.showToggleWatchedStatusButton] = showToggleWatchedStatusButton
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct LocationsSettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
var includePublicLocations = true
|
||||
var includedInstancesIDs = Set<Instance.ID>()
|
||||
var includedAccountsIDs = Set<Account.ID>()
|
||||
var includedAccountsPasswords = [Account.ID: String]()
|
||||
|
||||
init(
|
||||
json: JSON,
|
||||
includePublicLocations: Bool = true,
|
||||
includedInstancesIDs: Set<Instance.ID> = [],
|
||||
includedAccountsIDs: Set<Account.ID> = [],
|
||||
includedAccountsPasswords: [Account.ID: String] = [:]
|
||||
) {
|
||||
self.json = json
|
||||
self.includePublicLocations = includePublicLocations
|
||||
self.includedInstancesIDs = includedInstancesIDs
|
||||
self.includedAccountsIDs = includedAccountsIDs
|
||||
self.includedAccountsPasswords = includedAccountsPasswords
|
||||
}
|
||||
|
||||
var instances: [Instance] {
|
||||
if let instances = json["instances"].array {
|
||||
return instances.compactMap { instanceJSON in
|
||||
let dict = instanceJSON.dictionaryValue.mapValues { json in json.stringValue }
|
||||
return InstancesBridge().deserialize(dict)
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
var accounts: [Account] {
|
||||
if let accounts = json["accounts"].array {
|
||||
return accounts.compactMap { accountJSON in
|
||||
let dict = accountJSON.dictionaryValue.mapValues { json in json.stringValue }
|
||||
return AccountsBridge().deserialize(dict)
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
func performImport() {
|
||||
if includePublicLocations {
|
||||
Defaults[.instancesManifest] = json["instancesManifest"].string ?? ""
|
||||
Defaults[.countryOfPublicInstances] = json["countryOfPublicInstances"].string ?? ""
|
||||
}
|
||||
|
||||
instances.filter { includedInstancesIDs.contains($0.id) }.forEach { instance in
|
||||
_ = InstancesModel.shared.insert(id: instance.id, app: instance.app, name: instance.name, url: instance.apiURLString)
|
||||
}
|
||||
|
||||
if let accounts = json["accounts"].array {
|
||||
for accountJSON in accounts {
|
||||
let dict = accountJSON.dictionaryValue.mapValues { json in json.stringValue }
|
||||
if let account = AccountsBridge().deserialize(dict),
|
||||
includedAccountsIDs.contains(account.id)
|
||||
{
|
||||
var password = account.password
|
||||
if password?.isEmpty ?? true {
|
||||
password = includedAccountsPasswords[account.id]
|
||||
}
|
||||
if let password,
|
||||
!password.isEmpty,
|
||||
let instanceID = account.instanceID,
|
||||
let instance = InstancesModel.shared.find(instanceID) ?? InstancesModel.shared.findByURLString(account.urlString)
|
||||
{
|
||||
if !instance.accounts.contains(where: { instanceAccount in
|
||||
let (username, _) = instanceAccount.credentials
|
||||
return username == account.username
|
||||
}) {
|
||||
_ = AccountsModel.add(instance: instance, id: account.id, name: account.name, username: account.username, password: password)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct OtherDataSettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let lastAccountID = json["lastAccountID"].string {
|
||||
Defaults[.lastAccountID] = lastAccountID
|
||||
}
|
||||
|
||||
if let lastInstanceID = json["lastInstanceID"].string {
|
||||
Defaults[.lastInstanceID] = lastInstanceID
|
||||
}
|
||||
|
||||
if let playerRate = json["playerRate"].double {
|
||||
Defaults[.playerRate] = playerRate
|
||||
}
|
||||
|
||||
if let trendingCategoryString = json["trendingCategory"].string,
|
||||
let trendingCategory = TrendingCategory(rawValue: trendingCategoryString)
|
||||
{
|
||||
Defaults[.trendingCategory] = trendingCategory
|
||||
}
|
||||
|
||||
if let trendingCountryString = json["trendingCountry"].string,
|
||||
let trendingCountry = Country(rawValue: trendingCountryString)
|
||||
{
|
||||
Defaults[.trendingCountry] = trendingCountry
|
||||
}
|
||||
|
||||
if let subscriptionsViewPageString = json["subscriptionsViewPage"].string,
|
||||
let subscriptionsViewPage = SubscriptionsView.Page(rawValue: subscriptionsViewPageString)
|
||||
{
|
||||
Defaults[.subscriptionsViewPage] = subscriptionsViewPage
|
||||
}
|
||||
|
||||
if let subscriptionsListingStyle = json["subscriptionsListingStyle"].string {
|
||||
Defaults[.subscriptionsListingStyle] = ListingStyle(rawValue: subscriptionsListingStyle) ?? .list
|
||||
}
|
||||
|
||||
if let popularListingStyle = json["popularListingStyle"].string {
|
||||
Defaults[.popularListingStyle] = ListingStyle(rawValue: popularListingStyle) ?? .list
|
||||
}
|
||||
|
||||
if let trendingListingStyle = json["trendingListingStyle"].string {
|
||||
Defaults[.trendingListingStyle] = ListingStyle(rawValue: trendingListingStyle) ?? .list
|
||||
}
|
||||
|
||||
if let playlistListingStyle = json["playlistListingStyle"].string {
|
||||
Defaults[.playlistListingStyle] = ListingStyle(rawValue: playlistListingStyle) ?? .list
|
||||
}
|
||||
|
||||
if let channelPlaylistListingStyle = json["channelPlaylistListingStyle"].string {
|
||||
Defaults[.channelPlaylistListingStyle] = ListingStyle(rawValue: channelPlaylistListingStyle) ?? .list
|
||||
}
|
||||
|
||||
if let searchListingStyle = json["searchListingStyle"].string {
|
||||
Defaults[.searchListingStyle] = ListingStyle(rawValue: searchListingStyle) ?? .list
|
||||
}
|
||||
|
||||
if let hideShorts = json["hideShorts"].bool {
|
||||
Defaults[.hideShorts] = hideShorts
|
||||
}
|
||||
|
||||
if let hideWatched = json["hideWatched"].bool {
|
||||
Defaults[.hideWatched] = hideWatched
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct PlayerSettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let playerInstanceID = json["playerInstanceID"].string {
|
||||
Defaults[.playerInstanceID] = playerInstanceID
|
||||
}
|
||||
|
||||
if let pauseOnHidingPlayer = json["pauseOnHidingPlayer"].bool {
|
||||
Defaults[.pauseOnHidingPlayer] = pauseOnHidingPlayer
|
||||
}
|
||||
|
||||
if let closeVideoOnEOF = json["closeVideoOnEOF"].bool {
|
||||
Defaults[.closeVideoOnEOF] = closeVideoOnEOF
|
||||
}
|
||||
|
||||
if let exitFullscreenOnEOF = json["exitFullscreenOnEOF"].bool {
|
||||
Defaults[.exitFullscreenOnEOF] = exitFullscreenOnEOF
|
||||
}
|
||||
|
||||
if let expandVideoDescription = json["expandVideoDescription"].bool {
|
||||
Defaults[.expandVideoDescription] = expandVideoDescription
|
||||
}
|
||||
|
||||
if let collapsedLinesDescription = json["collapsedLinesDescription"].int {
|
||||
Defaults[.collapsedLinesDescription] = collapsedLinesDescription
|
||||
}
|
||||
|
||||
if let showChapters = json["showChapters"].bool {
|
||||
Defaults[.showChapters] = showChapters
|
||||
}
|
||||
|
||||
if let showChapterThumbnails = json["showChapterThumbnails"].bool {
|
||||
Defaults[.showChapterThumbnails] = showChapterThumbnails
|
||||
}
|
||||
|
||||
if let showChapterThumbnailsOnlyWhenDifferent = json["showChapterThumbnailsOnlyWhenDifferent"].bool {
|
||||
Defaults[.showChapterThumbnailsOnlyWhenDifferent] = showChapterThumbnailsOnlyWhenDifferent
|
||||
}
|
||||
|
||||
if let expandChapters = json["expandChapters"].bool {
|
||||
Defaults[.expandChapters] = expandChapters
|
||||
}
|
||||
|
||||
if let showRelated = json["showRelated"].bool {
|
||||
Defaults[.showRelated] = showRelated
|
||||
}
|
||||
|
||||
if let showInspectorString = json["showInspector"].string,
|
||||
let showInspector = ShowInspectorSetting(rawValue: showInspectorString)
|
||||
{
|
||||
Defaults[.showInspector] = showInspector
|
||||
}
|
||||
|
||||
if let playerSidebarString = json["playerSidebar"].string,
|
||||
let playerSidebar = PlayerSidebarSetting(rawValue: playerSidebarString)
|
||||
{
|
||||
Defaults[.playerSidebar] = playerSidebar
|
||||
}
|
||||
|
||||
if let showKeywords = json["showKeywords"].bool {
|
||||
Defaults[.showKeywords] = showKeywords
|
||||
}
|
||||
|
||||
if let enableReturnYouTubeDislike = json["enableReturnYouTubeDislike"].bool {
|
||||
Defaults[.enableReturnYouTubeDislike] = enableReturnYouTubeDislike
|
||||
}
|
||||
|
||||
if let closePiPOnNavigation = json["closePiPOnNavigation"].bool {
|
||||
Defaults[.closePiPOnNavigation] = closePiPOnNavigation
|
||||
}
|
||||
|
||||
if let closePiPOnOpeningPlayer = json["closePiPOnOpeningPlayer"].bool {
|
||||
Defaults[.closePiPOnOpeningPlayer] = closePiPOnOpeningPlayer
|
||||
}
|
||||
|
||||
if let closePlayerOnOpeningPiP = json["closePlayerOnOpeningPiP"].bool {
|
||||
Defaults[.closePlayerOnOpeningPiP] = closePlayerOnOpeningPiP
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
if let pauseOnEnteringBackground = json["pauseOnEnteringBackground"].bool {
|
||||
Defaults[.pauseOnEnteringBackground] = pauseOnEnteringBackground
|
||||
}
|
||||
#endif
|
||||
|
||||
if let showComments = json["showComments"].bool {
|
||||
Defaults[.showComments] = showComments
|
||||
}
|
||||
#if !os(tvOS)
|
||||
if let showScrollToTopInComments = json["showScrollToTopInComments"].bool {
|
||||
Defaults[.showScrollToTopInComments] = showScrollToTopInComments
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
if let isOrientationLocked = json["isOrientationLocked"].bool {
|
||||
Defaults[.isOrientationLocked] = isOrientationLocked
|
||||
}
|
||||
|
||||
if let enterFullscreenInLandscape = json["enterFullscreenInLandscape"].bool {
|
||||
Defaults[.enterFullscreenInLandscape] = enterFullscreenInLandscape
|
||||
}
|
||||
|
||||
if let rotateToLandscapeOnEnterFullScreenString = json["rotateToLandscapeOnEnterFullScreen"].string,
|
||||
let rotateToLandscapeOnEnterFullScreen = FullScreenRotationSetting(rawValue: rotateToLandscapeOnEnterFullScreenString)
|
||||
{
|
||||
Defaults[.rotateToLandscapeOnEnterFullScreen] = rotateToLandscapeOnEnterFullScreen
|
||||
}
|
||||
#endif
|
||||
|
||||
if let captionsAutoShow = json["captionsAutoShow"].bool {
|
||||
Defaults[.captionsAutoShow] = captionsAutoShow
|
||||
}
|
||||
|
||||
if let captionsDefaultLanguageCode = json["captionsDefaultLanguageCode"].string {
|
||||
Defaults[.captionsDefaultLanguageCode] = captionsDefaultLanguageCode
|
||||
}
|
||||
|
||||
if let captionsFallbackLanguageCode = json["captionsFallbackLanguageCode"].string {
|
||||
Defaults[.captionsFallbackLanguageCode] = captionsFallbackLanguageCode
|
||||
}
|
||||
|
||||
if let captionsFontScaleSize = json["captionsFontScaleSize"].string {
|
||||
Defaults[.captionsFontScaleSize] = captionsFontScaleSize
|
||||
}
|
||||
|
||||
if let captionsFontColor = json["captionsFontColor"].string {
|
||||
Defaults[.captionsFontColor] = captionsFontColor
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct QualitySettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let batteryCellularProfileString = json["batteryCellularProfile"].string {
|
||||
Defaults[.batteryCellularProfile] = batteryCellularProfileString
|
||||
}
|
||||
|
||||
if let batteryNonCellularProfileString = json["batteryNonCellularProfile"].string {
|
||||
Defaults[.batteryNonCellularProfile] = batteryNonCellularProfileString
|
||||
}
|
||||
|
||||
if let chargingCellularProfileString = json["chargingCellularProfile"].string {
|
||||
Defaults[.chargingCellularProfile] = chargingCellularProfileString
|
||||
}
|
||||
|
||||
if let chargingNonCellularProfileString = json["chargingNonCellularProfile"].string {
|
||||
Defaults[.chargingNonCellularProfile] = chargingNonCellularProfileString
|
||||
}
|
||||
|
||||
if let forceAVPlayerForLiveStreams = json["forceAVPlayerForLiveStreams"].bool {
|
||||
Defaults[.forceAVPlayerForLiveStreams] = forceAVPlayerForLiveStreams
|
||||
}
|
||||
|
||||
if let qualityProfiles = json["qualityProfiles"].array {
|
||||
for qualityProfileJSON in qualityProfiles {
|
||||
let dict = qualityProfileJSON.dictionaryValue.mapValues { json in json.stringValue }
|
||||
if let item = QualityProfileBridge().deserialize(dict) {
|
||||
QualityProfilesModel.shared.update(item, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct RecentlyOpenedImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let recentlyOpened = json["recentlyOpened"].array {
|
||||
for recentlyOpenedJSON in recentlyOpened {
|
||||
let dict = recentlyOpenedJSON.dictionaryValue.mapValues { json in json.stringValue }
|
||||
if let item = RecentItemBridge().deserialize(dict) {
|
||||
RecentsModel.shared.add(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct SponsorBlockSettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let sponsorBlockInstance = json["sponsorBlockInstance"].string {
|
||||
Defaults[.sponsorBlockInstance] = sponsorBlockInstance
|
||||
}
|
||||
|
||||
if let sponsorBlockCategories = json["sponsorBlockCategories"].array {
|
||||
Defaults[.sponsorBlockCategories] = Set(sponsorBlockCategories.compactMap(\.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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import Foundation
|
||||
|
||||
final class MenuModel: ObservableObject {
|
||||
static let shared = MenuModel()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var cancellables = [AnyCancellable]()
|
||||
|
||||
init() {
|
||||
registerChildModel(AccountsModel.shared)
|
||||
@@ -12,16 +12,10 @@ final class MenuModel: ObservableObject {
|
||||
}
|
||||
|
||||
func registerChildModel<T: ObservableObject>(_ model: T?) {
|
||||
guard let model else {
|
||||
guard !model.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
model.objectWillChange
|
||||
.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)
|
||||
cancellables.append(model!.objectWillChange.sink { [weak self] _ in self?.objectWillChange.send() })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,10 +107,6 @@ final class NavigationModel: ObservableObject {
|
||||
|
||||
@Published var presentingFileImporter = false
|
||||
|
||||
@Published var presentingSettingsImportSheet = false
|
||||
@Published var presentingSettingsFileImporter = false
|
||||
@Published var settingsImportURL: URL?
|
||||
|
||||
func openChannel(_ channel: Channel, navigationStyle: NavigationStyle) {
|
||||
guard channel.id != Video.fixtureChannelID else {
|
||||
return
|
||||
@@ -273,8 +269,6 @@ final class NavigationModel: ObservableObject {
|
||||
presentingChannel = false
|
||||
presentingPlaylist = false
|
||||
presentingOpenVideos = false
|
||||
presentingFileImporter = false
|
||||
presentingSettingsImportSheet = false
|
||||
}
|
||||
|
||||
func hideKeyboard() {
|
||||
@@ -285,9 +279,8 @@ final class NavigationModel: ObservableObject {
|
||||
|
||||
func presentAlert(title: String, message: String? = nil) {
|
||||
let message = message.isNil ? nil : Text(message!)
|
||||
let alert = Alert(title: Text(title), message: message)
|
||||
|
||||
presentAlert(alert)
|
||||
alert = Alert(title: Text(title), message: message)
|
||||
presentingAlert = true
|
||||
}
|
||||
|
||||
func presentRequestErrorAlert(_ error: RequestError) {
|
||||
@@ -296,11 +289,6 @@ final class NavigationModel: ObservableObject {
|
||||
}
|
||||
|
||||
func presentAlert(_ alert: Alert) {
|
||||
guard !presentingSettings else {
|
||||
SettingsModel.shared.presentAlert(alert)
|
||||
return
|
||||
}
|
||||
|
||||
self.alert = alert
|
||||
presentingAlert = true
|
||||
}
|
||||
@@ -318,21 +306,11 @@ final class NavigationModel: ObservableObject {
|
||||
func multipleTapHandler() {
|
||||
switch tabSelection {
|
||||
case .search:
|
||||
break
|
||||
self.search.focused = true
|
||||
default:
|
||||
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
|
||||
|
||||
@@ -26,7 +26,7 @@ final class NetworkStateModel: ObservableObject {
|
||||
}
|
||||
|
||||
var bufferingStateText: String? {
|
||||
guard detailsAvailable, player.hasStarted else { return nil }
|
||||
guard detailsAvailable else { return nil }
|
||||
return String(format: "%.0f%%", bufferingState)
|
||||
}
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ struct OpenVideosModel {
|
||||
if prepending {
|
||||
videos.reverse()
|
||||
}
|
||||
for video in videos {
|
||||
videos.forEach { video in
|
||||
player.enqueueVideo(video, play: false, prepending: prepending, loadDetails: false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,11 +40,6 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
|
||||
var isLoadingVideo = false
|
||||
|
||||
var hasStarted = false
|
||||
var isPaused: Bool {
|
||||
avPlayer.timeControlStatus == .paused
|
||||
}
|
||||
|
||||
var isPlaying: Bool {
|
||||
avPlayer.timeControlStatus == .playing
|
||||
}
|
||||
@@ -102,7 +97,7 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
|
||||
private var frequentTimeObserver: Any?
|
||||
private var infrequentTimeObserver: Any?
|
||||
private var playerTimeControlStatusObserver: NSKeyValueObservation?
|
||||
private var playerTimeControlStatusObserver: Any?
|
||||
|
||||
private var statusObservation: NSKeyValueObservation?
|
||||
|
||||
@@ -110,14 +105,6 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
|
||||
var controlsUpdates = false
|
||||
|
||||
// Retry mechanism
|
||||
private var retryCount = 0
|
||||
private let maxRetries = 3
|
||||
private var currentRetryStream: Stream?
|
||||
private var currentRetryVideo: Video?
|
||||
private var currentRetryPreservingTime = false
|
||||
private var currentRetryUpgrading = false
|
||||
|
||||
init() {
|
||||
addFrequentTimeObserver()
|
||||
addInfrequentTimeObserver()
|
||||
@@ -127,44 +114,28 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
#if os(iOS)
|
||||
controller.player = avPlayer
|
||||
#endif
|
||||
logger.info("AVPlayerBackend initialized.")
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Invalidate any observers to avoid memory leaks
|
||||
statusObservation?.invalidate()
|
||||
playerTimeControlStatusObserver?.invalidate()
|
||||
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream? {
|
||||
let sortedByResolution = streams
|
||||
.filter { ($0.kind == .adaptive || $0.kind == .stream) && $0.resolution <= maxResolution.value }
|
||||
.sorted { $0.resolution > $1.resolution }
|
||||
|
||||
// Remove any time observers added to AVPlayer
|
||||
if let frequentObserver = frequentTimeObserver {
|
||||
avPlayer.removeTimeObserver(frequentObserver)
|
||||
}
|
||||
if let infrequentObserver = infrequentTimeObserver {
|
||||
avPlayer.removeTimeObserver(infrequentObserver)
|
||||
}
|
||||
|
||||
// Remove notification observers
|
||||
removeItemDidPlayToEndTimeObserver()
|
||||
|
||||
logger.info("AVPlayerBackend deinitialized.")
|
||||
return streams.first { $0.kind == .hls } ??
|
||||
sortedByResolution.first { $0.kind == .stream } ??
|
||||
sortedByResolution.first
|
||||
}
|
||||
|
||||
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(
|
||||
_ stream: Stream,
|
||||
of video: Video,
|
||||
preservingTime: Bool,
|
||||
upgrading: Bool
|
||||
upgrading _: Bool
|
||||
) {
|
||||
// Store stream and video for potential retries
|
||||
currentRetryStream = stream
|
||||
currentRetryVideo = video
|
||||
currentRetryPreservingTime = preservingTime
|
||||
currentRetryUpgrading = upgrading
|
||||
|
||||
isLoadingVideo = true
|
||||
|
||||
if let url = stream.singleAssetURL {
|
||||
@@ -174,7 +145,7 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
_ = url.startAccessingSecurityScopedResource()
|
||||
}
|
||||
|
||||
loadSingleAsset(url, stream: stream, of: video, preservingTime: preservingTime, upgrading: upgrading)
|
||||
loadSingleAsset(url, stream: stream, of: video, preservingTime: preservingTime)
|
||||
} else {
|
||||
model.logger.info("playing stream with many assets:")
|
||||
model.logger.info("composition audio asset: \(stream.audioAsset.url)")
|
||||
@@ -189,22 +160,7 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
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()
|
||||
|
||||
// Setting hasStarted to true the first time player started
|
||||
if !hasStarted {
|
||||
hasStarted = true
|
||||
}
|
||||
|
||||
model.objectWillChange.send()
|
||||
}
|
||||
|
||||
@@ -212,6 +168,7 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
guard avPlayer.timeControlStatus != .paused else {
|
||||
return
|
||||
}
|
||||
|
||||
avPlayer.pause()
|
||||
model.objectWillChange.send()
|
||||
}
|
||||
@@ -226,7 +183,6 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
|
||||
func stop() {
|
||||
avPlayer.replaceCurrentItem(with: nil)
|
||||
hasStarted = false
|
||||
}
|
||||
|
||||
func cancelLoads() {
|
||||
@@ -263,23 +219,21 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
_ url: URL,
|
||||
stream: Stream,
|
||||
of video: Video,
|
||||
preservingTime: Bool = false,
|
||||
upgrading: Bool = false
|
||||
preservingTime: Bool = false
|
||||
) {
|
||||
asset?.cancelLoading()
|
||||
asset = AVURLAsset(
|
||||
url: url,
|
||||
options: ["AVURLAssetHTTPHeaderFieldsKey": ["User-Agent": "\(UserAgentManager.shared.userAgent)"]]
|
||||
)
|
||||
asset = AVURLAsset(url: url)
|
||||
asset?.loadValuesAsynchronously(forKeys: Self.assetKeysToLoad) { [weak self] in
|
||||
var error: NSError?
|
||||
switch self?.asset?.statusOfValue(forKey: "duration", error: &error) {
|
||||
case .loaded:
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.insertPlayerItem(stream, for: video, preservingTime: preservingTime, upgrading: upgrading)
|
||||
self?.insertPlayerItem(stream, for: video, preservingTime: preservingTime)
|
||||
}
|
||||
case .failed:
|
||||
self?.handleFileLoadError(error: error)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.model.playerError = error
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
@@ -349,17 +303,11 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
private func insertPlayerItem(
|
||||
_ stream: Stream,
|
||||
for video: Video,
|
||||
preservingTime: Bool = false,
|
||||
upgrading: Bool = false
|
||||
preservingTime: Bool = false
|
||||
) {
|
||||
removeItemDidPlayToEndTimeObserver()
|
||||
|
||||
model.playerItem = playerItem(stream)
|
||||
|
||||
if stream.isHLS {
|
||||
model.playerItem?.preferredPeakBitRate = Double(model.qualityProfile?.resolution.value.bitrate ?? 0)
|
||||
}
|
||||
|
||||
guard model.playerItem != nil else {
|
||||
return
|
||||
}
|
||||
@@ -377,7 +325,7 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
|
||||
let startPlaying = {
|
||||
#if !os(macOS)
|
||||
self.model.setAudioSessionActive(true)
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
#endif
|
||||
|
||||
self.setRate(self.model.currentRate)
|
||||
@@ -439,7 +387,7 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
}
|
||||
|
||||
if preservingTime {
|
||||
if model.preservedTime.isNil || upgrading {
|
||||
if model.preservedTime.isNil {
|
||||
model.saveTime {
|
||||
replaceItemAndSeek()
|
||||
startPlaying()
|
||||
@@ -540,9 +488,6 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
|
||||
switch playerItem.status {
|
||||
case .readyToPlay:
|
||||
// Reset retry state on successful load
|
||||
self.resetRetryState()
|
||||
|
||||
if self.model.activeBackend == .appleAVPlayer,
|
||||
self.isAutoplaying(playerItem)
|
||||
{
|
||||
@@ -578,9 +523,10 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .failed:
|
||||
self.handleFileLoadError(error: item.error)
|
||||
DispatchQueue.main.async {
|
||||
self.model.playerError = item.error
|
||||
}
|
||||
|
||||
default:
|
||||
return
|
||||
@@ -813,7 +759,7 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
opened = true
|
||||
controller.startPictureInPicture()
|
||||
} else {
|
||||
self.logger.info("PiP not possible, waited \(delay) seconds")
|
||||
print("PiP not possible, waited \(delay) seconds")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -849,44 +795,4 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
func setNeedsDrawing(_: Bool) {}
|
||||
func setSize(_: Double, _: Double) {}
|
||||
func setNeedsNetworkStateUpdates(_: Bool) {}
|
||||
|
||||
private func handleFileLoadError(error: Error?) {
|
||||
guard let stream = currentRetryStream, let video = currentRetryVideo else {
|
||||
// No stream info available, show error immediately
|
||||
DispatchQueue.main.async {
|
||||
self.model.playerError = error
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if retryCount < maxRetries {
|
||||
retryCount += 1
|
||||
let delay = TimeInterval(retryCount * 2) // 2, 4, 6 seconds
|
||||
|
||||
logger.warning("File load failed. Retry attempt \(retryCount) of \(maxRetries) after \(delay) seconds...")
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.logger.info("Retrying file load (attempt \(self.retryCount))...")
|
||||
self.playStream(stream, of: video, preservingTime: self.currentRetryPreservingTime, upgrading: self.currentRetryUpgrading)
|
||||
}
|
||||
} else {
|
||||
// All retries exhausted, show error
|
||||
logger.error("File load failed after \(maxRetries) retry attempts")
|
||||
DispatchQueue.main.async {
|
||||
self.model.playerError = error
|
||||
}
|
||||
|
||||
// Reset retry counter for next attempt
|
||||
resetRetryState()
|
||||
}
|
||||
}
|
||||
|
||||
private func resetRetryState() {
|
||||
retryCount = 0
|
||||
currentRetryStream = nil
|
||||
currentRetryVideo = nil
|
||||
currentRetryPreservingTime = false
|
||||
currentRetryUpgrading = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,15 @@ import AVFAudio
|
||||
import CoreMedia
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Libmpv
|
||||
import Logging
|
||||
import MediaPlayer
|
||||
import MPVKit
|
||||
import Repeat
|
||||
import SwiftUI
|
||||
|
||||
final class MPVBackend: PlayerBackend {
|
||||
static var timeUpdateInterval = 0.5
|
||||
static var networkStateUpdateInterval = 0.1
|
||||
static var refreshRateUpdateInterval = 0.5
|
||||
static var networkStateUpdateInterval = 1.0
|
||||
|
||||
private var logger = Logger(label: "mpv-backend")
|
||||
|
||||
@@ -23,14 +22,13 @@ final class MPVBackend: PlayerBackend {
|
||||
|
||||
var stream: Stream?
|
||||
var video: Video?
|
||||
var captions: Captions? {
|
||||
didSet {
|
||||
Task {
|
||||
await handleCaptionsChange()
|
||||
}
|
||||
var captions: Captions? { didSet {
|
||||
guard let captions else {
|
||||
client?.removeSubs()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
addSubTrack(captions.url)
|
||||
}}
|
||||
var currentTime: CMTime?
|
||||
|
||||
var loadedVideo = false
|
||||
@@ -46,8 +44,6 @@ final class MPVBackend: PlayerBackend {
|
||||
}
|
||||
}}
|
||||
|
||||
var hasStarted = false
|
||||
var isPaused = false
|
||||
var isPlaying = true { didSet {
|
||||
networkStateTimer.start()
|
||||
|
||||
@@ -91,19 +87,12 @@ final class MPVBackend: PlayerBackend {
|
||||
|
||||
private var clientTimer: Repeater!
|
||||
private var networkStateTimer: Repeater!
|
||||
private var refreshRateTimer: Repeater!
|
||||
|
||||
private var onFileLoaded: (() -> Void)?
|
||||
|
||||
var controlsUpdates = false
|
||||
private var timeObserverThrottle = Throttle(interval: 2)
|
||||
|
||||
// Retry mechanism
|
||||
private var retryCount = 0
|
||||
private let maxRetries = 3
|
||||
private var currentRetryStream: Stream?
|
||||
private var currentRetryVideo: Video?
|
||||
|
||||
var suggestedPlaybackRates: [Double] {
|
||||
[0.25, 0.33, 0.5, 0.67, 0.75, 1, 1.25, 1.5, 1.75, 2, 3, 4]
|
||||
}
|
||||
@@ -192,42 +181,54 @@ final class MPVBackend: PlayerBackend {
|
||||
client?.audioSampleRate ?? "unknown"
|
||||
}
|
||||
|
||||
var availableAudioTracks: [Stream.AudioTrack] {
|
||||
stream?.audioTracks ?? []
|
||||
}
|
||||
|
||||
init() {
|
||||
// swiftlint:disable shorthand_optional_binding
|
||||
clientTimer = .init(interval: .seconds(Self.timeUpdateInterval), mode: .infinite) { [weak self] _ in
|
||||
guard let self, self.model.activeBackend == .mpv else {
|
||||
guard let self = self, self.model.activeBackend == .mpv else {
|
||||
return
|
||||
}
|
||||
self.getTimeUpdates()
|
||||
}
|
||||
|
||||
networkStateTimer = .init(interval: .seconds(Self.networkStateUpdateInterval), mode: .infinite) { [weak self] _ in
|
||||
guard let self, self.model.activeBackend == .mpv else {
|
||||
guard let self = self, self.model.activeBackend == .mpv else {
|
||||
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()
|
||||
}
|
||||
// swiftlint:enable shorthand_optional_binding
|
||||
}
|
||||
|
||||
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 {
|
||||
stream.format != nil && stream.format != .av1
|
||||
stream.resolution != .unknown && stream.format != .av1
|
||||
}
|
||||
|
||||
func playStream(_ stream: Stream, of video: Video, preservingTime: Bool, upgrading: Bool) {
|
||||
// Store stream and video for potential retries
|
||||
currentRetryStream = stream
|
||||
currentRetryVideo = video
|
||||
|
||||
#if !os(macOS)
|
||||
if model.presentingPlayer {
|
||||
DispatchQueue.main.async {
|
||||
@@ -237,29 +238,13 @@ final class MPVBackend: PlayerBackend {
|
||||
#endif
|
||||
|
||||
var captions: Captions?
|
||||
|
||||
if Defaults[.captionsAutoShow] == true {
|
||||
let captionsDefaultLanguageCode = Defaults[.captionsDefaultLanguageCode],
|
||||
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
|
||||
if let captionsLanguageCode = Defaults[.captionsLanguageCode] {
|
||||
captions = video.captions.first { $0.code == captionsLanguageCode } ??
|
||||
video.captions.first { $0.code.contains(captionsLanguageCode) }
|
||||
}
|
||||
|
||||
let updateCurrentStream = {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
if self?.video?.id != video.id {
|
||||
self?.model.selectedAudioTrackIndex = 0
|
||||
}
|
||||
self?.stream = stream
|
||||
self?.video = video
|
||||
self?.model.stream = stream
|
||||
@@ -269,7 +254,7 @@ final class MPVBackend: PlayerBackend {
|
||||
|
||||
let startPlaying = {
|
||||
#if !os(macOS)
|
||||
self.model.setAudioSessionActive(true)
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
#endif
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
@@ -279,9 +264,6 @@ final class MPVBackend: PlayerBackend {
|
||||
|
||||
self.startClientUpdates()
|
||||
|
||||
if Defaults[.captionsAutoShow] { self.client?.setSubToAuto() } else { self.client?.setSubToNo() }
|
||||
PlayerModel.shared.captions = self.captions
|
||||
|
||||
if !preservingTime,
|
||||
!upgrading,
|
||||
let segment = self.model.sponsorBlock.segments.first,
|
||||
@@ -327,7 +309,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
|
||||
}
|
||||
} else {
|
||||
@@ -336,40 +318,24 @@ final class MPVBackend: PlayerBackend {
|
||||
startPlaying()
|
||||
}
|
||||
|
||||
// Handle streams with multiple audio tracks
|
||||
if !stream.audioTracks.isEmpty {
|
||||
// Ensure the index is within bounds to prevent race conditions
|
||||
let safeIndex = min(max(0, stream.selectedAudioTrackIndex), stream.audioTracks.count - 1)
|
||||
stream.selectedAudioTrackIndex = safeIndex
|
||||
let fileToLoad = self.model.musicMode ? stream.audioAsset.url : stream.videoAsset.url
|
||||
let audioTrack = self.model.musicMode ? nil : stream.audioAsset.url
|
||||
|
||||
stream.audioAsset = AVURLAsset(url: stream.audioTracks[safeIndex].url)
|
||||
let fileToLoad = self.model.musicMode ? stream.audioAsset.url : stream.videoAsset.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
|
||||
self?.isLoadingVideo = true
|
||||
self?.pause()
|
||||
}
|
||||
} else {
|
||||
// Fallback for streams without separate audio tracks (e.g., single asset streams)
|
||||
let fileToLoad = stream.videoAsset.url
|
||||
|
||||
client.loadFile(fileToLoad, bitrate: stream.bitrate, kind: stream.kind, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
|
||||
self?.isLoadingVideo = true
|
||||
self?.pause()
|
||||
}
|
||||
client.loadFile(fileToLoad, audio: audioTrack, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
|
||||
self?.isLoadingVideo = true
|
||||
self?.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if preservingTime {
|
||||
if model.preservedTime.isNil || upgrading {
|
||||
if model.preservedTime.isNil {
|
||||
model.saveTime {
|
||||
replaceItem(self.model.preservedTime)
|
||||
}
|
||||
} else {
|
||||
replaceItem(model.preservedTime)
|
||||
replaceItem(self.model.preservedTime)
|
||||
}
|
||||
} else {
|
||||
replaceItem(nil)
|
||||
@@ -378,20 +344,9 @@ final class MPVBackend: PlayerBackend {
|
||||
startClientUpdates()
|
||||
}
|
||||
|
||||
func startRefreshRateUpdates() {
|
||||
refreshRateTimer.start()
|
||||
}
|
||||
|
||||
func stopRefreshRateUpdates() {
|
||||
refreshRateTimer.pause()
|
||||
}
|
||||
|
||||
func play() {
|
||||
#if !os(macOS)
|
||||
model.setAudioSessionActive(true)
|
||||
#endif
|
||||
isPlaying = true
|
||||
startClientUpdates()
|
||||
startRefreshRateUpdates()
|
||||
|
||||
if controls.presentingControls {
|
||||
startControlsUpdates()
|
||||
@@ -399,35 +354,14 @@ final class MPVBackend: PlayerBackend {
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
model.setAudioSessionActive(true)
|
||||
#endif
|
||||
|
||||
client?.play()
|
||||
|
||||
isPlaying = true
|
||||
isPaused = false
|
||||
|
||||
// Setting hasStarted to true the first time player started
|
||||
if !hasStarted {
|
||||
hasStarted = true
|
||||
}
|
||||
}
|
||||
|
||||
func pause() {
|
||||
isPlaying = false
|
||||
stopClientUpdates()
|
||||
stopRefreshRateUpdates()
|
||||
|
||||
client?.pause()
|
||||
isPaused = true
|
||||
isPlaying = false
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
@@ -443,12 +377,7 @@ final class MPVBackend: PlayerBackend {
|
||||
}
|
||||
|
||||
func stop() {
|
||||
stopClientUpdates()
|
||||
stopRefreshRateUpdates()
|
||||
client?.stop()
|
||||
isPlaying = false
|
||||
isPaused = false
|
||||
hasStarted = false
|
||||
}
|
||||
|
||||
func seek(to time: CMTime, seekType _: SeekType, completionHandler: ((Bool) -> Void)?) {
|
||||
@@ -464,25 +393,25 @@ final class MPVBackend: PlayerBackend {
|
||||
}
|
||||
|
||||
func closeItem() {
|
||||
pause()
|
||||
stop()
|
||||
video = nil
|
||||
stream = nil
|
||||
client?.pause()
|
||||
client?.stop()
|
||||
self.video = nil
|
||||
self.stream = nil
|
||||
}
|
||||
|
||||
func closePiP() {}
|
||||
|
||||
func startControlsUpdates() {
|
||||
guard model.presentingPlayer, model.controls.presentingControls, !model.controls.presentingOverlays else {
|
||||
logger.info("ignored controls update start")
|
||||
self.logger.info("ignored controls update start")
|
||||
return
|
||||
}
|
||||
logger.info("starting controls updates")
|
||||
self.logger.info("starting controls updates")
|
||||
controlsUpdates = true
|
||||
}
|
||||
|
||||
func stopControlsUpdates() {
|
||||
logger.info("stopping controls updates")
|
||||
self.logger.info("stopping controls updates")
|
||||
controlsUpdates = false
|
||||
}
|
||||
|
||||
@@ -496,28 +425,23 @@ final class MPVBackend: PlayerBackend {
|
||||
currentTime = client?.currentTime
|
||||
playerItemDuration = client?.duration
|
||||
|
||||
guard let currentTime else {
|
||||
return
|
||||
}
|
||||
|
||||
if controlsUpdates {
|
||||
updateControls()
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
model.setupAudioSessionForNowPlaying()
|
||||
model.updateNowPlayingInfo()
|
||||
#endif
|
||||
model.updateNowPlayingInfo()
|
||||
|
||||
handleSegmentsThrottle.execute {
|
||||
model.handleSegments(at: currentTime)
|
||||
if let currentTime {
|
||||
model.handleSegments(at: currentTime)
|
||||
}
|
||||
}
|
||||
|
||||
timeObserverThrottle.execute {
|
||||
self.model.updateWatch(time: currentTime)
|
||||
self.model.updateWatch(time: self.currentTime)
|
||||
}
|
||||
|
||||
model.updateTime(currentTime)
|
||||
self.model.updateTime(self.currentTime!)
|
||||
}
|
||||
|
||||
private func stopClientUpdates() {
|
||||
@@ -531,52 +455,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>!) {
|
||||
logger.info(.init(stringLiteral: "RECEIVED event: \(String(cString: mpv_event_name(event.pointee.event_id)))"))
|
||||
|
||||
@@ -595,13 +473,6 @@ final class MPVBackend: PlayerBackend {
|
||||
onFileLoaded?()
|
||||
startClientUpdates()
|
||||
onFileLoaded = nil
|
||||
// Reset retry state on successful load
|
||||
resetRetryState()
|
||||
// Re-activate audio session for Now Playing
|
||||
#if !os(macOS)
|
||||
model.setupAudioSessionForNowPlaying()
|
||||
model.updateNowPlayingInfo()
|
||||
#endif
|
||||
|
||||
case MPV_EVENT_PROPERTY_CHANGE:
|
||||
let dataOpaquePtr = OpaquePointer(event.pointee.data)
|
||||
@@ -617,24 +488,10 @@ final class MPVBackend: PlayerBackend {
|
||||
onFileLoaded?()
|
||||
startClientUpdates()
|
||||
onFileLoaded = nil
|
||||
// Reset retry state on successful playback restart
|
||||
resetRetryState()
|
||||
// Re-activate audio session for Now Playing
|
||||
#if !os(macOS)
|
||||
model.setupAudioSessionForNowPlaying()
|
||||
model.updateNowPlayingInfo()
|
||||
#endif
|
||||
|
||||
case MPV_EVENT_VIDEO_RECONFIG:
|
||||
model.updateAspectRatio()
|
||||
|
||||
case MPV_EVENT_AUDIO_RECONFIG:
|
||||
// Re-activate audio session when audio is reconfigured
|
||||
#if !os(macOS)
|
||||
model.setupAudioSessionForNowPlaying()
|
||||
model.updateNowPlayingInfo()
|
||||
#endif
|
||||
|
||||
case MPV_EVENT_SEEK:
|
||||
isSeeking = true
|
||||
|
||||
@@ -644,7 +501,10 @@ final class MPVBackend: PlayerBackend {
|
||||
if reason != MPV_END_FILE_REASON_STOP {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.handleFileLoadError()
|
||||
NavigationModel.shared.presentAlert(title: "Error while opening file")
|
||||
self.model.closeCurrentItem(finished: true)
|
||||
self.getTimeUpdates()
|
||||
self.eofPlaybackModeAction()
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.async { [weak self] in self?.handleEndOfFile() }
|
||||
@@ -659,49 +519,11 @@ final class MPVBackend: PlayerBackend {
|
||||
guard client.eofReached else {
|
||||
return
|
||||
}
|
||||
|
||||
getTimeUpdates()
|
||||
eofPlaybackModeAction()
|
||||
}
|
||||
|
||||
private func handleFileLoadError() {
|
||||
guard let stream = currentRetryStream, let video = currentRetryVideo else {
|
||||
// No stream info available, show error immediately
|
||||
NavigationModel.shared.presentAlert(title: "Error while opening file")
|
||||
model.closeCurrentItem(finished: true)
|
||||
getTimeUpdates()
|
||||
eofPlaybackModeAction()
|
||||
return
|
||||
}
|
||||
|
||||
if retryCount < maxRetries {
|
||||
retryCount += 1
|
||||
let delay = TimeInterval(retryCount * 2) // 2, 4, 6 seconds
|
||||
|
||||
logger.warning("File load failed. Retry attempt \(retryCount) of \(maxRetries) after \(delay) seconds...")
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.logger.info("Retrying file load (attempt \(self.retryCount))...")
|
||||
self.playStream(stream, of: video, preservingTime: true, upgrading: false)
|
||||
}
|
||||
} else {
|
||||
// All retries exhausted, show error
|
||||
logger.error("File load failed after \(maxRetries) retry attempts")
|
||||
NavigationModel.shared.presentAlert(title: "Error while opening file")
|
||||
model.closeCurrentItem(finished: true)
|
||||
getTimeUpdates()
|
||||
eofPlaybackModeAction()
|
||||
|
||||
// Reset retry counter for next attempt
|
||||
resetRetryState()
|
||||
}
|
||||
}
|
||||
|
||||
private func resetRetryState() {
|
||||
retryCount = 0
|
||||
currentRetryStream = nil
|
||||
currentRetryVideo = nil
|
||||
}
|
||||
|
||||
func setNeedsDrawing(_ needsDrawing: Bool) {
|
||||
client?.setNeedsDrawing(needsDrawing)
|
||||
}
|
||||
@@ -715,14 +537,8 @@ final class MPVBackend: PlayerBackend {
|
||||
}
|
||||
|
||||
func addSubTrack(_ url: URL) {
|
||||
Task {
|
||||
if let areSubtitlesAdded = client?.areSubtitlesAdded {
|
||||
if await areSubtitlesAdded() {
|
||||
await client?.removeSubs()
|
||||
}
|
||||
}
|
||||
await client?.addSubTrack(url)
|
||||
}
|
||||
client?.removeSubs()
|
||||
client?.addSubTrack(url)
|
||||
}
|
||||
|
||||
func setVideoToAuto() {
|
||||
@@ -786,17 +602,6 @@ final class MPVBackend: PlayerBackend {
|
||||
}
|
||||
}
|
||||
|
||||
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":
|
||||
@@ -822,19 +627,4 @@ final class MPVBackend: PlayerBackend {
|
||||
logger.info("MPV backend received unhandled property: \(name)")
|
||||
}
|
||||
}
|
||||
|
||||
func switchAudioTrack(to index: Int) {
|
||||
guard let stream, let video else { return }
|
||||
|
||||
// Validate the index is within bounds
|
||||
guard index >= 0, index < stream.audioTracks.count else {
|
||||
logger.error("Invalid audio track index: \(index), available tracks: \(stream.audioTracks.count)")
|
||||
return
|
||||
}
|
||||
|
||||
stream.selectedAudioTrackIndex = index
|
||||
model.saveTime { [weak self] in
|
||||
self?.playStream(stream, of: video, preservingTime: true, upgrading: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import CoreMedia
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Libmpv
|
||||
import Logging
|
||||
import MPVKit
|
||||
#if !os(macOS)
|
||||
import Siesta
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
final class MPVClient: ObservableObject {
|
||||
@@ -16,8 +14,6 @@ final class MPVClient: ObservableObject {
|
||||
}
|
||||
|
||||
private var logger = Logger(label: "mpv-client")
|
||||
private var needsDrawingCooldown = false
|
||||
private var needsDrawingWorkItem: DispatchWorkItem?
|
||||
|
||||
var mpv: OpaquePointer!
|
||||
var mpvGL: OpaquePointer!
|
||||
@@ -31,7 +27,6 @@ final class MPVClient: ObservableObject {
|
||||
var backend: MPVBackend!
|
||||
|
||||
var seeking = false
|
||||
var currentRefreshRate = 60
|
||||
|
||||
func create(frame: CGRect? = nil) {
|
||||
#if !os(macOS)
|
||||
@@ -42,7 +37,7 @@ final class MPVClient: ObservableObject {
|
||||
|
||||
mpv = mpv_create()
|
||||
if mpv == nil {
|
||||
logger.critical("failed creating context\n")
|
||||
print("failed creating context\n")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
@@ -65,73 +60,13 @@ final class MPVClient: ObservableObject {
|
||||
checkError(mpv_set_option_string(mpv, "input-media-keys", "yes"))
|
||||
#endif
|
||||
|
||||
// CACHING //
|
||||
|
||||
checkError(mpv_set_option_string(mpv, "cache-pause-initial", Defaults[.mpvCachePauseInital] ? "yes" : "no"))
|
||||
checkError(mpv_set_option_string(mpv, "cache-pause-initial", "yes"))
|
||||
checkError(mpv_set_option_string(mpv, "cache-secs", Defaults[.mpvCacheSecs]))
|
||||
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, "deinterlace", Defaults[.mpvDeinterlace] ? "yes" : "no"))
|
||||
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)"))
|
||||
|
||||
// AUDIO //
|
||||
|
||||
// GPU //
|
||||
|
||||
checkError(mpv_set_option_string(mpv, "hwdec", Defaults[.mpvHWdec]))
|
||||
checkError(mpv_set_option_string(mpv, "hwdec", machine == "x86_64" ? "no" : "auto-safe"))
|
||||
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-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))
|
||||
|
||||
@@ -141,7 +76,7 @@ final class MPVClient: ObservableObject {
|
||||
get_proc_address_ctx: nil
|
||||
)
|
||||
|
||||
queue = DispatchQueue(label: "mpv", qos: .userInteractive, attributes: [.concurrent])
|
||||
queue = DispatchQueue(label: "mpv")
|
||||
|
||||
withUnsafeMutablePointer(to: &initParams) { initParams in
|
||||
var params = [
|
||||
@@ -151,7 +86,7 @@ final class MPVClient: ObservableObject {
|
||||
]
|
||||
|
||||
if mpv_render_context_create(&mpvGL, mpv, ¶ms) < 0 {
|
||||
logger.critical("failed to initialize mpv GL context")
|
||||
print("failed to initialize mpv GL context")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
@@ -192,8 +127,6 @@ final class MPVClient: ObservableObject {
|
||||
func loadFile(
|
||||
_ url: URL,
|
||||
audio: URL? = nil,
|
||||
bitrate: Int? = nil,
|
||||
kind: Stream.Kind,
|
||||
sub: URL? = nil,
|
||||
time: CMTime? = nil,
|
||||
forceSeekable: Bool = false,
|
||||
@@ -204,10 +137,6 @@ final class MPVClient: ObservableObject {
|
||||
|
||||
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 {
|
||||
options.append("start=\(Int(time.seconds))")
|
||||
}
|
||||
@@ -230,10 +159,6 @@ final class MPVClient: ObservableObject {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -347,31 +272,6 @@ final class MPVClient: ObservableObject {
|
||||
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 { 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) {
|
||||
guard !seeking else {
|
||||
logger.warning("ignoring seek, another in progress")
|
||||
@@ -415,7 +315,7 @@ final class MPVClient: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async(qos: .userInteractive) { [weak self] in
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
let model = self.backend.model
|
||||
let aspectRatio = self.aspectRatio > 0 && self.aspectRatio < VideoPlayerView.defaultAspectRatio ? self.aspectRatio : VideoPlayerView.defaultAspectRatio
|
||||
@@ -424,7 +324,7 @@ final class MPVClient: ObservableObject {
|
||||
#if os(iOS)
|
||||
insets = OrientationTracker.shared.currentInterfaceOrientation.isPortrait ? SafeAreaModel.shared.safeArea.bottom : 0
|
||||
#endif
|
||||
let offsetY = max(0, model.playingFullScreen ? ((model.playerSize.height / 2.0) - (height / 2)) : 0)
|
||||
let offsetY = max(0, model.playingFullScreen ? ((model.playerSize.height / 2.0) - ((height + insets) / 2)) : 0)
|
||||
UIView.animate(withDuration: 0.2, animations: {
|
||||
self.glView?.frame = CGRect(x: 0, y: offsetY, width: roundedWidth, height: height)
|
||||
}) { completion in
|
||||
@@ -443,30 +343,10 @@ final class MPVClient: ObservableObject {
|
||||
}
|
||||
|
||||
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)")
|
||||
|
||||
// Set the cooldown flag to true and cancel any existing work item
|
||||
needsDrawingCooldown = true
|
||||
needsDrawingWorkItem?.cancel()
|
||||
|
||||
#if !os(macOS)
|
||||
glView?.needsDrawing = needsDrawing
|
||||
#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(
|
||||
@@ -494,56 +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)
|
||||
} 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.")
|
||||
}
|
||||
#endif
|
||||
|
||||
currentRefreshRate = refreshRate
|
||||
return refreshRate
|
||||
}
|
||||
|
||||
func addVideoTrack(_ url: URL) {
|
||||
command("video-add", args: [url.absoluteString])
|
||||
}
|
||||
|
||||
func addSubTrack(_ url: URL) async {
|
||||
await Task {
|
||||
command("sub-add", args: [url.absoluteString])
|
||||
}.value
|
||||
func addSubTrack(_ url: URL) {
|
||||
command("sub-add", args: [url.absoluteString])
|
||||
}
|
||||
|
||||
func removeSubs() async {
|
||||
await Task {
|
||||
command("sub-remove")
|
||||
}.value
|
||||
func removeSubs() {
|
||||
command("sub-remove")
|
||||
}
|
||||
|
||||
func setVideoToAuto() {
|
||||
@@ -554,22 +394,6 @@ final class MPVClient: ObservableObject {
|
||||
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 {
|
||||
Int(getString("track-list/count") ?? "-1") ?? -1
|
||||
}
|
||||
@@ -665,8 +489,11 @@ final class MPVClient: ObservableObject {
|
||||
func glUpdate(_ ctx: UnsafeMutableRawPointer?) {
|
||||
let videoLayer = unsafeBitCast(ctx, to: VideoLayer.self)
|
||||
|
||||
// Request a redraw when MPV signals that new content is available
|
||||
videoLayer.requestRedraw()
|
||||
videoLayer.client?.queue?.async {
|
||||
if !videoLayer.isAsynchronous {
|
||||
videoLayer.display()
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
func getProcAddress(_: UnsafeMutableRawPointer?, _ name: UnsafePointer<Int8>?) -> UnsafeMutableRawPointer? {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import CoreMedia
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Logging
|
||||
#if !os(macOS)
|
||||
import UIKit
|
||||
#endif
|
||||
@@ -20,8 +19,6 @@ protocol PlayerBackend {
|
||||
var loadedVideo: Bool { get }
|
||||
var isLoadingVideo: Bool { get }
|
||||
|
||||
var hasStarted: Bool { get }
|
||||
var isPaused: Bool { get }
|
||||
var isPlaying: Bool { get }
|
||||
var isSeeking: Bool { get }
|
||||
var playerItemDuration: CMTime? { get }
|
||||
@@ -32,6 +29,7 @@ protocol PlayerBackend {
|
||||
var videoWidth: Double? { get }
|
||||
var videoHeight: Double? { get }
|
||||
|
||||
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream?
|
||||
func canPlay(_ stream: Stream) -> Bool
|
||||
func canPlayAtRate(_ rate: Double) -> Bool
|
||||
|
||||
@@ -76,10 +74,6 @@ protocol PlayerBackend {
|
||||
}
|
||||
|
||||
extension PlayerBackend {
|
||||
var logger: Logger {
|
||||
return Logger(label: "stream.yattee.player.backend")
|
||||
}
|
||||
|
||||
func seek(to time: CMTime, seekType: SeekType, completionHandler: ((Bool) -> Void)? = nil) {
|
||||
model.seek.registerSeek(at: time, type: seekType, restore: currentTime)
|
||||
seek(to: time, seekType: seekType, completionHandler: completionHandler)
|
||||
@@ -116,22 +110,15 @@ extension PlayerBackend {
|
||||
model.prepareCurrentItemForHistory(finished: true)
|
||||
|
||||
if model.queue.isEmpty {
|
||||
#if os(tvOS)
|
||||
if Defaults[.closeVideoOnEOF] {
|
||||
if Defaults[.closeVideoOnEOF] {
|
||||
#if os(tvOS)
|
||||
if model.activeBackend == .appleAVPlayer {
|
||||
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 {
|
||||
model.advanceToNextItem()
|
||||
}
|
||||
@@ -144,111 +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
|
||||
// Safety: Ensure resolution exists before comparing
|
||||
guard let streamResolution = $0.resolution else {
|
||||
logger.info("Stream ID: \($0.id) has nil resolution, skipping")
|
||||
return false
|
||||
}
|
||||
let isWithinResolution = streamResolution <= maxResolution.value
|
||||
|
||||
logger.info("Stream ID: \($0.id) - Kind: \(String(describing: $0.kind)) - Resolution: \(String(describing: streamResolution)) - Bitrate: \($0.bitrate ?? 0)")
|
||||
logger.info("Is HLS: \(isHLS), Is within resolution: \(isWithinResolution)")
|
||||
|
||||
logger.info("video url: \($0.videoAsset?.url.absoluteString ?? "nil"), audio url: \($0.audioAsset?.url.absoluteString ?? "nil")")
|
||||
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
|
||||
// Safety check: Ensure stream has a resolution
|
||||
guard let streamResolution = stream.resolution else {
|
||||
logger.info("Filtered stream ID: \(stream.id) has nil resolution, excluding")
|
||||
return false
|
||||
}
|
||||
// Check if the stream's resolution is within the maximum allowed resolution
|
||||
let isWithinResolution = streamResolution <= 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
|
||||
// Safety check: Ensure both streams have resolutions
|
||||
guard let lhsResolution = lhs.resolution, let rhsResolution = rhs.resolution else {
|
||||
logger.info("One or both streams missing resolution - LHS: \(lhs.id), RHS: \(rhs.id)")
|
||||
// If lhs has no resolution, it's "less than" rhs (prefer rhs)
|
||||
// If rhs has no resolution, it's "less than" lhs (prefer lhs)
|
||||
return lhs.resolution == nil
|
||||
}
|
||||
|
||||
if lhsResolution == rhsResolution {
|
||||
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: lhsResolution)), RHS Resolution: \(String(describing: rhsResolution))")
|
||||
|
||||
return lhsResolution < rhsResolution
|
||||
}
|
||||
|
||||
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) {
|
||||
logger.info("updating controls")
|
||||
print("updating controls")
|
||||
|
||||
guard model.presentingPlayer, !model.controls.presentingOverlays else {
|
||||
logger.info("ignored controls update")
|
||||
print("ignored controls update")
|
||||
completionHandler?()
|
||||
return
|
||||
}
|
||||
@@ -256,7 +143,7 @@ extension PlayerBackend {
|
||||
DispatchQueue.main.async(qos: .userInteractive) {
|
||||
#if !os(macOS)
|
||||
guard UIApplication.shared.applicationState != .background else {
|
||||
logger.info("not performing controls updates in background")
|
||||
print("not performing controls updates in background")
|
||||
completionHandler?()
|
||||
return
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -52,7 +52,7 @@ extension PlayerModel {
|
||||
func playItem(_ item: PlayerQueueItem, at time: CMTime? = nil) {
|
||||
advancing = false
|
||||
|
||||
if !playingInPictureInPicture, !currentItem.isNil, backend != nil {
|
||||
if !playingInPictureInPicture, !currentItem.isNil {
|
||||
backend.closeItem()
|
||||
}
|
||||
|
||||
@@ -94,9 +94,7 @@ extension PlayerModel {
|
||||
}
|
||||
} else {
|
||||
self.videoBeingOpened = nil
|
||||
self.streamsWithInstance(instance: playerInstance, streams: video.streams) { processedStreams in
|
||||
self.availableStreams = processedStreams
|
||||
}
|
||||
self.availableStreams = self.streamsWithInstance(instance: playerInstance, streams: video.streams)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,40 +123,16 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
var streamByQualityProfile: Stream? {
|
||||
// Safety check: Ensure backend is available
|
||||
guard backend != nil else {
|
||||
logger.error("Backend is nil when trying to select stream by quality profile")
|
||||
return nil
|
||||
}
|
||||
|
||||
let profile = qualityProfile ?? .defaultProfile
|
||||
|
||||
// First attempt: Filter by both `canPlay` and `isPreferred`
|
||||
if let streamPreferredForProfile = backend.bestPlayable(
|
||||
availableStreams.filter { backend.canPlay($0) && profile.isPreferred($0) },
|
||||
maxResolution: profile.resolution, formatOrder: profile.formats
|
||||
maxResolution: profile.resolution
|
||||
) {
|
||||
return streamPreferredForProfile
|
||||
}
|
||||
|
||||
// Fallback: Filter by `canPlay` only
|
||||
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
|
||||
return backend.bestPlayable(availableStreams.filter { backend.canPlay($0) }, maxResolution: profile.resolution)
|
||||
}
|
||||
|
||||
func advanceToNextItem() {
|
||||
@@ -235,9 +209,7 @@ extension PlayerModel {
|
||||
self.removeQueueItems()
|
||||
}
|
||||
|
||||
if backend != nil {
|
||||
backend.closeItem()
|
||||
}
|
||||
backend.closeItem()
|
||||
}
|
||||
|
||||
@discardableResult func enqueueVideo(
|
||||
@@ -329,7 +301,7 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
restoredQueue.append(contentsOf: Defaults[.queue])
|
||||
queue = restoredQueue.compactMap(\.self)
|
||||
queue = restoredQueue.compactMap { $0 }
|
||||
queue.forEach { loadQueueVideoDetails($0) }
|
||||
}
|
||||
|
||||
@@ -367,31 +339,6 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
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
|
||||
if let errorDictionary = error.json.dictionaryObject,
|
||||
let errorMessage = errorDictionary["message"] ?? errorDictionary["error"],
|
||||
|
||||
@@ -44,6 +44,22 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
@@ -53,14 +69,6 @@ extension PlayerModel {
|
||||
self?.segmentRestorationTime = time
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Siesta
|
||||
import SwiftUI
|
||||
@@ -42,9 +41,7 @@ extension PlayerModel {
|
||||
self.logger.info("ignoring loaded streams from \(instance.description) as current video has changed")
|
||||
return
|
||||
}
|
||||
self.streamsWithInstance(instance: instance, streams: video.streams) { processedStreams in
|
||||
self.availableStreams = processedStreams
|
||||
}
|
||||
self.availableStreams = self.streamsWithInstance(instance: instance, streams: video.streams)
|
||||
} else {
|
||||
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) {
|
||||
// Queue for stream processing
|
||||
let streamProcessingQueue = DispatchQueue(label: "stream.yattee.streamProcessing.Queue")
|
||||
// Queue for accessing the processedStreams array
|
||||
let processedStreamsQueue = DispatchQueue(label: "stream.yattee.processedStreams.Queue")
|
||||
// DispatchGroup for managing multiple tasks
|
||||
let streamProcessingGroup = DispatchGroup()
|
||||
func streamsWithInstance(instance: Instance, streams: [Stream]) -> [Stream] {
|
||||
streams.map { stream in
|
||||
stream.instance = instance
|
||||
|
||||
var processedStreams = [Stream]()
|
||||
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 != nil && 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if instance.app == .invidious, instance.proxiesVideos {
|
||||
if let audio = stream.audioAsset {
|
||||
stream.audioAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: audio)
|
||||
}
|
||||
forbiddenAssetTestGroup.wait()
|
||||
|
||||
// Post-processing code
|
||||
if instance.app == .invidious, hasForbiddenAsset || instance.proxiesVideos {
|
||||
if let audio = stream.audioAsset {
|
||||
stream.audioAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: audio)
|
||||
}
|
||||
if let video = stream.videoAsset {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Append to processedStreams within the processedStreamsQueue
|
||||
processedStreamsQueue.sync {
|
||||
processedStreams.append(stream)
|
||||
if let video = stream.videoAsset {
|
||||
stream.videoAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: video)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
streamProcessingGroup.notify(queue: .main) {
|
||||
// Access and pass processedStreams within the processedStreamsQueue block
|
||||
processedStreamsQueue.sync {
|
||||
completion(processedStreams)
|
||||
}
|
||||
return stream
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
func streamsSorter(_ lhs: Stream, _ rhs: Stream) -> Bool {
|
||||
if lhs.resolution.isNil || rhs.resolution.isNil {
|
||||
return lhs.kind < rhs.kind
|
||||
}
|
||||
|
||||
// Compare either kind or resolution based on conditions
|
||||
return lhs.kind == rhs.kind ? (lhsRes > rhsRes) : (lhs.kind < rhs.kind)
|
||||
return lhs.kind == rhs.kind ? (lhs.resolution.height > rhs.resolution.height) : (lhs.kind < rhs.kind)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ extension PlayerModel {
|
||||
streamsMenu,
|
||||
playbackModeMenu,
|
||||
switchToMPVAction
|
||||
].compactMap(\.self)
|
||||
].compactMap { $0 }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,10 @@ struct ScreenSaverManager {
|
||||
return false
|
||||
}
|
||||
|
||||
noSleepReturn = IOPMAssertionCreateWithName(
|
||||
kIOPMAssertionTypeNoDisplaySleep as CFString,
|
||||
IOPMAssertionLevel(kIOPMAssertionLevelOn),
|
||||
reason as CFString,
|
||||
&noSleepAssertion
|
||||
)
|
||||
noSleepReturn = IOPMAssertionCreateWithName(kIOPMAssertionTypeNoDisplaySleep as CFString,
|
||||
IOPMAssertionLevel(kIOPMAssertionLevelOn),
|
||||
reason as CFString,
|
||||
&noSleepAssertion)
|
||||
return noSleepReturn == kIOReturnSuccess
|
||||
}
|
||||
|
||||
|
||||
@@ -69,10 +69,7 @@ final class PlaylistsModel: ObservableObject {
|
||||
.onSuccess { resource in
|
||||
self.error = nil
|
||||
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)
|
||||
onSuccess()
|
||||
}
|
||||
|
||||
@@ -3,15 +3,15 @@ import Foundation
|
||||
|
||||
struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
|
||||
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 {
|
||||
case avc1
|
||||
case stream
|
||||
case webm
|
||||
case mp4
|
||||
case av1
|
||||
case hls
|
||||
case stream
|
||||
case mp4
|
||||
case avc1
|
||||
case av1
|
||||
case webm
|
||||
|
||||
var id: String {
|
||||
rawValue
|
||||
@@ -23,6 +23,7 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
|
||||
return "Stream"
|
||||
case .webm:
|
||||
return "WebM"
|
||||
|
||||
default:
|
||||
return rawValue.uppercased()
|
||||
}
|
||||
@@ -30,18 +31,18 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
|
||||
|
||||
var streamFormat: Stream.Format? {
|
||||
switch self {
|
||||
case .avc1:
|
||||
return .avc1
|
||||
case .stream:
|
||||
return nil
|
||||
case .webm:
|
||||
return .webm
|
||||
case .mp4:
|
||||
return .mp4
|
||||
case .av1:
|
||||
return .av1
|
||||
case .hls:
|
||||
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,21 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
|
||||
var backend: PlayerBackendType
|
||||
var resolution: ResolutionSetting
|
||||
var formats: [Format]
|
||||
var order: [Int]
|
||||
|
||||
var description: String {
|
||||
if let name, !name.isEmpty { return name }
|
||||
return "\(backend.label) - \(resolution.description) - \(formatsDescription)"
|
||||
}
|
||||
|
||||
var formatsDescription: String {
|
||||
switch formats.count {
|
||||
case Format.allCases.count:
|
||||
if formats.count == Format.allCases.count {
|
||||
return "Any format".localized()
|
||||
case 0:
|
||||
return "No format selected".localized()
|
||||
case 1 ... 3:
|
||||
return formats.map(\.description).joined(separator: ", ")
|
||||
default:
|
||||
return String(format: "%@ formats".localized(), String(formats.count))
|
||||
}
|
||||
if formats.count <= 3 {
|
||||
return formats.map(\.description).joined(separator: ", ")
|
||||
}
|
||||
|
||||
return String(format: "%@ formats".localized(), String(formats.count))
|
||||
}
|
||||
|
||||
func isPreferred(_ stream: Stream) -> Bool {
|
||||
@@ -76,24 +75,13 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
|
||||
return true
|
||||
}
|
||||
|
||||
// Safety check: Ensure stream has a resolution
|
||||
guard let streamResolution = stream.resolution else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Safety check: Ensure stream has a format
|
||||
guard let streamFormat = stream.format else {
|
||||
return false
|
||||
}
|
||||
|
||||
let defaultResolution = Stream.Resolution.custom(height: 720, refreshRate: 30)
|
||||
let resolutionMatch = resolution.value ?? defaultResolution >= streamResolution
|
||||
let resolutionMatch = !stream.resolution.isNil && resolution.value >= stream.resolution
|
||||
|
||||
if resolutionMatch, formats.contains(.stream), stream.kind == .stream {
|
||||
return true
|
||||
}
|
||||
|
||||
let formatMatch = formats.compactMap(\.streamFormat).contains(streamFormat)
|
||||
let formatMatch = formats.compactMap(\.streamFormat).contains(stream.format)
|
||||
|
||||
return resolutionMatch && formatMatch
|
||||
}
|
||||
@@ -113,8 +101,7 @@ struct QualityProfileBridge: Defaults.Bridge {
|
||||
"name": value.name ?? "",
|
||||
"backend": value.backend.rawValue,
|
||||
"resolution": value.resolution.rawValue,
|
||||
"formats": value.formats.map(\.rawValue).joined(separator: Self.formatsSeparator),
|
||||
"order": value.order.map { String($0) }.joined(separator: Self.formatsSeparator) // New line
|
||||
"formats": value.formats.map { $0.rawValue }.joined(separator: Self.formatsSeparator)
|
||||
]
|
||||
}
|
||||
|
||||
@@ -129,8 +116,7 @@ struct QualityProfileBridge: Defaults.Bridge {
|
||||
|
||||
let name = object["name"]
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,11 @@ final class SearchModel: ObservableObject {
|
||||
@Published var querySuggestions = [String]()
|
||||
private var suggestionsDebouncer = Debouncer(.milliseconds(200))
|
||||
|
||||
@Default(.showSearchSuggestions) private var showSearchSuggestions
|
||||
@Published var focused = false
|
||||
|
||||
#if os(macOS)
|
||||
#if os(iOS)
|
||||
var textField: UITextField!
|
||||
#elseif os(macOS)
|
||||
var textField: NSTextField!
|
||||
#endif
|
||||
|
||||
@@ -26,9 +28,15 @@ final class SearchModel: ObservableObject {
|
||||
private var resource: Resource!
|
||||
|
||||
init() {
|
||||
#if os(iOS)
|
||||
addKeyboardDidHideNotificationObserver()
|
||||
#endif
|
||||
}
|
||||
|
||||
deinit {
|
||||
#if os(iOS)
|
||||
removeKeyboardDidHideNotificationObserver()
|
||||
#endif
|
||||
}
|
||||
|
||||
var isLoading: Bool {
|
||||
@@ -94,7 +102,7 @@ final class SearchModel: ObservableObject {
|
||||
}}
|
||||
|
||||
func loadSuggestions(_ query: String) {
|
||||
guard accounts.app.supportsSearchSuggestions, showSearchSuggestions else {
|
||||
guard accounts.app.supportsSearchSuggestions else {
|
||||
querySuggestions.removeAll()
|
||||
return
|
||||
}
|
||||
@@ -148,4 +156,18 @@ final class SearchModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private func addKeyboardDidHideNotificationObserver() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(onKeyboardDidHide), name: UIResponder.keyboardDidHideNotification, object: nil)
|
||||
}
|
||||
|
||||
@objc func onKeyboardDidHide() {
|
||||
focused = false
|
||||
}
|
||||
|
||||
private func removeKeyboardDidHideNotificationObserver() {
|
||||
NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardDidHideNotification, object: nil)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -71,13 +71,13 @@ final class SeekModel: ObservableObject {
|
||||
func showOSD() {
|
||||
guard !presentingOSD else { return }
|
||||
|
||||
presentingOSD = true
|
||||
withAnimation(.easeIn(duration: 0.1)) { self.presentingOSD = true }
|
||||
}
|
||||
|
||||
func hideOSD() {
|
||||
guard presentingOSD else { return }
|
||||
|
||||
presentingOSD = false
|
||||
withAnimation(.easeIn(duration: 0.1)) { self.presentingOSD = false }
|
||||
}
|
||||
|
||||
func hideOSDWithDelay() {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
enum SeekType: Equatable {
|
||||
case chapterSkip(String)
|
||||
case segmentSkip(String)
|
||||
case segmentRestore
|
||||
case userInteracted
|
||||
|
||||
@@ -7,9 +7,6 @@ final class SettingsModel: ObservableObject {
|
||||
@Published var presentingAlert = false
|
||||
@Published var alert = Alert(title: Text("Error"))
|
||||
|
||||
@Published var presentingSettingsImportSheet = false
|
||||
@Published var settingsImportURL: URL?
|
||||
|
||||
func presentAlert(title: String, message: String? = nil) {
|
||||
let message = message.isNil ? nil : Text(message!)
|
||||
alert = Alert(title: Text(title), message: message)
|
||||
@@ -20,9 +17,4 @@ final class SettingsModel: ObservableObject {
|
||||
self.alert = alert
|
||||
presentingAlert = true
|
||||
}
|
||||
|
||||
func presentSettingsImportSheet(_ url: URL) {
|
||||
settingsImportURL = url
|
||||
presentingSettingsImportSheet = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import Logging
|
||||
import SwiftyJSON
|
||||
|
||||
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")
|
||||
|
||||
@@ -21,19 +21,15 @@ final class SponsorBlockAPI: ObservableObject {
|
||||
case "sponsor":
|
||||
return "Sponsor".localized()
|
||||
case "selfpromo":
|
||||
return "Unpaid/Self Promotion".localized()
|
||||
case "interaction":
|
||||
return "Interaction Reminder (Subscribe)".localized()
|
||||
return "Self-promotion".localized()
|
||||
case "intro":
|
||||
return "Intermission/Intro Animation".localized()
|
||||
return "Intro".localized()
|
||||
case "outro":
|
||||
return "Endcards/Credits".localized()
|
||||
case "preview":
|
||||
return "Preview/Recap/Hook".localized()
|
||||
case "filler":
|
||||
return "Filler Tangent/Jokes".localized()
|
||||
return "Outro".localized()
|
||||
case "interaction":
|
||||
return "Interaction".localized()
|
||||
case "music_offtopic":
|
||||
return "Music: Non-Music Section".localized()
|
||||
return "Offtopic in Music Videos".localized()
|
||||
default:
|
||||
return name.capitalized
|
||||
}
|
||||
@@ -50,14 +46,9 @@ final class SponsorBlockAPI: ObservableObject {
|
||||
"The creator will receive payment or compensation in the form of money or free products.").localized()
|
||||
|
||||
case "selfpromo":
|
||||
return ("The creator will not receive any payment in exchange for this promotion. " +
|
||||
"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. " +
|
||||
return ("Promoting a product or service that is directly related to the creator themselves. " +
|
||||
"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":
|
||||
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()
|
||||
@@ -65,11 +56,8 @@ final class SponsorBlockAPI: ObservableObject {
|
||||
case "outro":
|
||||
return "Typically near or at the end of the video when the credits pop up and/or endcards are shown.".localized()
|
||||
|
||||
case "preview":
|
||||
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()
|
||||
|
||||
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 "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 "music_offtopic":
|
||||
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.logger.info("loaded \(self.segments.count) SponsorBlock segments")
|
||||
for segment in self.segments {
|
||||
self.logger.info("\(segment.start) -> \(segment.end)")
|
||||
self.segments.forEach {
|
||||
self.logger.info("\($0.start) -> \($0.end)")
|
||||
}
|
||||
case let .failure(error):
|
||||
self.segments = []
|
||||
|
||||
@@ -4,7 +4,7 @@ import Siesta
|
||||
final class Store<Data>: ResourceObserver, ObservableObject {
|
||||
@Published private var all: Data?
|
||||
|
||||
var collection: Data { all ?? ([item].compactMap(\.self) as! Data) }
|
||||
var collection: Data { all ?? ([item].compactMap { $0 } as! Data) }
|
||||
var item: Data? { all }
|
||||
|
||||
init(_ data: Data? = nil) {
|
||||
|
||||
@@ -4,130 +4,67 @@ import Foundation
|
||||
|
||||
// swiftlint:disable:next final_class
|
||||
class Stream: Equatable, Hashable, Identifiable {
|
||||
enum Resolution: Comparable, Codable, Defaults.Serializable {
|
||||
case predefined(PredefinedResolution)
|
||||
case custom(height: Int, refreshRate: Int)
|
||||
|
||||
enum PredefinedResolution: String, CaseIterable, Codable {
|
||||
// 8K UHD (16:9) Resolutions
|
||||
case hd4320p60, hd4320p30
|
||||
|
||||
// 4K UHD (16:9) Resolutions
|
||||
case hd2160p60, hd2160p30
|
||||
|
||||
// 1440p (16:9) Resolutions
|
||||
case hd1440p60, hd1440p30
|
||||
|
||||
// 1080p (Full HD, 16:9) Resolutions
|
||||
case hd1080p60, hd1080p30
|
||||
|
||||
// 720p (HD, 16:9) Resolutions
|
||||
case hd720p60, hd720p30
|
||||
|
||||
// Standard Definition (SD) Resolutions
|
||||
case sd480p30
|
||||
case sd360p30
|
||||
case sd240p30
|
||||
case sd144p30
|
||||
}
|
||||
enum Resolution: String, CaseIterable, Comparable, Defaults.Serializable {
|
||||
case hd2160p60
|
||||
case hd2160p50
|
||||
case hd2160p48
|
||||
case hd2160p30
|
||||
case hd1440p60
|
||||
case hd1440p50
|
||||
case hd1440p48
|
||||
case hd1440p30
|
||||
case hd1080p60
|
||||
case hd1080p50
|
||||
case hd1080p48
|
||||
case hd1080p30
|
||||
case hd720p60
|
||||
case hd720p50
|
||||
case hd720p48
|
||||
case hd720p30
|
||||
case sd480p30
|
||||
case sd360p30
|
||||
case sd240p30
|
||||
case sd144p30
|
||||
case unknown
|
||||
|
||||
var name: String {
|
||||
switch self {
|
||||
case let .predefined(predefined):
|
||||
return predefined.rawValue
|
||||
case let .custom(height, refreshRate):
|
||||
return "\(height)p\(refreshRate != 30 ? ", \(refreshRate) fps" : "")"
|
||||
}
|
||||
"\(height)p\(refreshRate != -1 && refreshRate != 30 ? ", \(refreshRate) fps" : "")"
|
||||
}
|
||||
|
||||
var height: Int {
|
||||
switch self {
|
||||
case let .predefined(predefined):
|
||||
return predefined.height
|
||||
case let .custom(height, _):
|
||||
return height
|
||||
if self == .unknown {
|
||||
return -1
|
||||
}
|
||||
|
||||
let resolutionPart = rawValue.components(separatedBy: "p").first!
|
||||
return Int(resolutionPart.components(separatedBy: CharacterSet.decimalDigits.inverted).joined())!
|
||||
}
|
||||
|
||||
var refreshRate: Int {
|
||||
switch self {
|
||||
case let .predefined(predefined):
|
||||
return predefined.refreshRate
|
||||
case let .custom(_, refreshRate):
|
||||
return refreshRate
|
||||
if self == .unknown {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
var bitrate: Int {
|
||||
switch self {
|
||||
case let .predefined(predefined):
|
||||
return predefined.bitrate
|
||||
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
|
||||
let refreshRatePart = rawValue.components(separatedBy: "p")[1]
|
||||
|
||||
if refreshRatePart.isEmpty {
|
||||
return 30
|
||||
}
|
||||
|
||||
return Int(refreshRatePart.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? -1
|
||||
}
|
||||
|
||||
static func from(resolution: String, fps: Int? = nil) -> Self {
|
||||
if let predefined = PredefinedResolution(rawValue: resolution) {
|
||||
return .predefined(predefined)
|
||||
}
|
||||
|
||||
// Attempt to parse height and refresh rate
|
||||
if let height = Int(resolution.components(separatedBy: "p").first ?? ""), height > 0 {
|
||||
let refreshRate = fps ?? 30
|
||||
return .custom(height: height, refreshRate: refreshRate)
|
||||
}
|
||||
|
||||
// Default behavior if parsing fails
|
||||
return .custom(height: 720, refreshRate: 30)
|
||||
allCases.first { $0.rawValue.contains(resolution) && $0.refreshRate == (fps ?? 30) } ?? .unknown
|
||||
}
|
||||
|
||||
static func < (lhs: Self, rhs: Self) -> Bool {
|
||||
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 {
|
||||
case hls, adaptive, stream
|
||||
case stream, adaptive, hls
|
||||
|
||||
private var sortOrder: Int {
|
||||
switch self {
|
||||
@@ -145,23 +82,37 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
enum Format: String {
|
||||
case avc1
|
||||
case mp4
|
||||
case av1
|
||||
enum Format: String, Comparable {
|
||||
case webm
|
||||
case hls
|
||||
case stream
|
||||
case avc1
|
||||
case av1
|
||||
case mp4
|
||||
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 {
|
||||
switch self {
|
||||
case .webm:
|
||||
return "WebM"
|
||||
case .hls:
|
||||
return "adaptive (HLS)"
|
||||
case .stream:
|
||||
return "Stream"
|
||||
|
||||
default:
|
||||
return rawValue.uppercased()
|
||||
}
|
||||
@@ -170,47 +121,22 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
static func from(_ string: String) -> Self {
|
||||
let lowercased = string.lowercased()
|
||||
|
||||
if lowercased.contains("webm") {
|
||||
return .webm
|
||||
}
|
||||
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") {
|
||||
return .webm
|
||||
}
|
||||
if lowercased.contains("stream") {
|
||||
return .stream
|
||||
}
|
||||
if lowercased.contains("hls") {
|
||||
return .hls
|
||||
if lowercased.contains("mpeg_4") || lowercased.contains("mp4") {
|
||||
return .mp4
|
||||
}
|
||||
return .unknown
|
||||
}
|
||||
}
|
||||
|
||||
struct AudioTrack: Hashable, Identifiable {
|
||||
let id = UUID().uuidString
|
||||
let url: URL
|
||||
let content: String?
|
||||
let language: String?
|
||||
|
||||
var displayLanguage: String {
|
||||
LanguageCodes(rawValue: language ?? "")?.description.capitalized ?? language ?? "Unknown"
|
||||
}
|
||||
|
||||
var description: String {
|
||||
"\(displayLanguage) (\(content ?? "Original"))"
|
||||
}
|
||||
|
||||
var isDubbed: Bool {
|
||||
content?.lowercased().starts(with: "dubbed") ?? false
|
||||
}
|
||||
}
|
||||
|
||||
let id = UUID()
|
||||
|
||||
var instance: Instance!
|
||||
@@ -225,10 +151,6 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
|
||||
var encoding: String?
|
||||
var videoFormat: String?
|
||||
var bitrate: Int?
|
||||
var requestRange: String?
|
||||
var audioTracks: [AudioTrack] = []
|
||||
var selectedAudioTrackIndex = 0
|
||||
|
||||
init(
|
||||
instance: Instance? = nil,
|
||||
@@ -239,10 +161,7 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
resolution: Resolution? = nil,
|
||||
kind: Kind = .hls,
|
||||
encoding: String? = nil,
|
||||
videoFormat: String? = nil,
|
||||
bitrate: Int? = nil,
|
||||
requestRange: String? = nil,
|
||||
audioTracks: [AudioTrack] = []
|
||||
videoFormat: String? = nil
|
||||
) {
|
||||
self.instance = instance
|
||||
self.audioAsset = audioAsset
|
||||
@@ -253,9 +172,6 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
self.kind = kind
|
||||
self.encoding = encoding
|
||||
format = .from(videoFormat ?? "")
|
||||
self.bitrate = bitrate
|
||||
self.requestRange = requestRange
|
||||
self.audioTracks = audioTracks
|
||||
}
|
||||
|
||||
var isLocal: Bool {
|
||||
@@ -268,30 +184,22 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
|
||||
var quality: String {
|
||||
guard localURL.isNil else { return "Opened File" }
|
||||
|
||||
if kind == .hls {
|
||||
return "adaptive (HLS)"
|
||||
}
|
||||
|
||||
return resolution.name
|
||||
return kind == .hls ? "adaptive (HLS)" : "\(resolution.name)\(kind == .stream ? " (\(kind.rawValue))" : "")"
|
||||
}
|
||||
|
||||
var shortQuality: String {
|
||||
guard localURL.isNil else { return "File" }
|
||||
|
||||
if kind == .hls {
|
||||
return "adaptive (HLS)"
|
||||
return "HLS"
|
||||
}
|
||||
|
||||
if kind == .stream {
|
||||
return resolution.name
|
||||
}
|
||||
return resolutionAndFormat
|
||||
return resolution?.name ?? "?"
|
||||
}
|
||||
|
||||
var description: String {
|
||||
guard localURL.isNil else { return resolutionAndFormat }
|
||||
return format != .hls ? "\(resolutionAndFormat)" : "adaptive (HLS)"
|
||||
let instanceString = instance.isNil ? "" : " - (\(instance!.description))"
|
||||
return "\(resolutionAndFormat)\(instanceString)"
|
||||
}
|
||||
|
||||
var resolutionAndFormat: String {
|
||||
@@ -338,97 +246,3 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Stream.Resolution.PredefinedResolution {
|
||||
var height: Int {
|
||||
switch self {
|
||||
// 8K UHD (16:9) Resolutions
|
||||
case .hd4320p60, .hd4320p30:
|
||||
return 4320
|
||||
|
||||
// 4K UHD (16:9) Resolutions
|
||||
case .hd2160p60, .hd2160p30:
|
||||
return 2160
|
||||
|
||||
// 1440p (16:9) Resolutions
|
||||
case .hd1440p60, .hd1440p30:
|
||||
return 1440
|
||||
|
||||
// 1080p (Full HD, 16:9) Resolutions
|
||||
case .hd1080p60, .hd1080p30:
|
||||
return 1080
|
||||
|
||||
// 720p (HD, 16:9) Resolutions
|
||||
case .hd720p60, .hd720p30:
|
||||
return 720
|
||||
|
||||
// Standard Definition (SD) Resolutions
|
||||
case .sd480p30:
|
||||
return 480
|
||||
|
||||
case .sd360p30:
|
||||
return 360
|
||||
|
||||
case .sd240p30:
|
||||
return 240
|
||||
|
||||
case .sd144p30:
|
||||
return 144
|
||||
}
|
||||
}
|
||||
|
||||
var refreshRate: Int {
|
||||
switch self {
|
||||
// 60 fps Resolutions
|
||||
case .hd4320p60, .hd2160p60, .hd1440p60, .hd1080p60, .hd720p60:
|
||||
return 60
|
||||
|
||||
// 30 fps Resolutions
|
||||
case .hd4320p30, .hd2160p30, .hd1440p30, .hd1080p30, .hd720p30,
|
||||
.sd480p30, .sd360p30, .sd240p30, .sd144p30:
|
||||
return 30
|
||||
}
|
||||
}
|
||||
|
||||
// These values are an approximation.
|
||||
// https://support.google.com/youtube/answer/1722171?hl=en#zippy=%2Cbitrate
|
||||
|
||||
var bitrate: Int {
|
||||
switch self {
|
||||
// 8K UHD (16:9) Resolutions
|
||||
case .hd4320p60:
|
||||
return 180_000_000 // Midpoint between 120 Mbps and 240 Mbps
|
||||
case .hd4320p30:
|
||||
return 120_000_000 // Midpoint between 80 Mbps and 160 Mbps
|
||||
// 4K UHD (16:9) Resolutions
|
||||
case .hd2160p60:
|
||||
return 60_500_000 // Midpoint between 53 Mbps and 68 Mbps
|
||||
case .hd2160p30:
|
||||
return 40_000_000 // Midpoint between 35 Mbps and 45 Mbps
|
||||
// 1440p (2K) Resolutions
|
||||
case .hd1440p60:
|
||||
return 24_000_000 // 24 Mbps
|
||||
case .hd1440p30:
|
||||
return 16_000_000 // 16 Mbps
|
||||
// 1080p (Full HD, 16:9) Resolutions
|
||||
case .hd1080p60:
|
||||
return 12_000_000 // 12 Mbps
|
||||
case .hd1080p30:
|
||||
return 8_000_000 // 8 Mbps
|
||||
// 720p (HD, 16:9) Resolutions
|
||||
case .hd720p60:
|
||||
return 7_500_000 // 7.5 Mbps
|
||||
case .hd720p30:
|
||||
return 5_000_000 // 5 Mbps
|
||||
// Standard Definition (SD) Resolutions
|
||||
case .sd480p30:
|
||||
return 2_500_000 // 2.5 Mbps
|
||||
case .sd360p30:
|
||||
return 1_000_000 // 1 Mbps
|
||||
case .sd240p30:
|
||||
return 1_000_000 // 1 Mbps
|
||||
case .sd144p30:
|
||||
return 600_000 // 0.6 Mbps
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,27 +5,10 @@ final class ThumbnailsModel: ObservableObject {
|
||||
static var shared = ThumbnailsModel()
|
||||
|
||||
@Published var unloadable = Set<URL>()
|
||||
private var retryCounts = [URL: Int]()
|
||||
private let maxRetries = 3
|
||||
private let retryDelay: TimeInterval = 1.0
|
||||
|
||||
func insertUnloadable(_ url: URL) {
|
||||
let retries = (retryCounts[url] ?? 0) + 1
|
||||
|
||||
if retries >= maxRetries {
|
||||
DispatchQueue.main.async {
|
||||
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
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.unloadable.insert(url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,23 +20,21 @@ final class ThumbnailsModel: ObservableObject {
|
||||
return unloadable.contains(url)
|
||||
}
|
||||
|
||||
func best(_ video: Video) -> (url: URL?, quality: Thumbnail.Quality?) {
|
||||
func best(_ video: Video) -> URL? {
|
||||
for quality in availableQualitites {
|
||||
let url = video.thumbnailURL(quality: quality)
|
||||
if !isUnloadable(url) {
|
||||
return (url, quality)
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
return (nil, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
private var availableQualitites: [Thumbnail.Quality] {
|
||||
switch Defaults[.thumbnailsQuality] {
|
||||
case .highest:
|
||||
return [.maxres, .high, .medium, .default]
|
||||
case .high:
|
||||
return [.high, .medium, .default]
|
||||
return [.maxresdefault, .medium, .default]
|
||||
case .medium:
|
||||
return [.medium, .default]
|
||||
case .low:
|
||||
|
||||
@@ -114,7 +114,7 @@ struct URLBookmarkModel {
|
||||
func refreshAll() {
|
||||
logger.info("refreshing all bookmarks")
|
||||
|
||||
for url in allURLs {
|
||||
allURLs.forEach { url in
|
||||
if loadBookmark(url) != nil {
|
||||
logger.info("bookmark for \(url) exists")
|
||||
} else {
|
||||
|
||||
@@ -9,6 +9,7 @@ final class UnwatchedFeedCountModel: ObservableObject {
|
||||
|
||||
private var accounts = AccountsModel.shared
|
||||
|
||||
// swiftlint:disable empty_count
|
||||
var unwatchedText: Text? {
|
||||
if let account = accounts.current,
|
||||
!account.anonymous,
|
||||
@@ -31,4 +32,5 @@ final class UnwatchedFeedCountModel: ObservableObject {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// swiftlint:enable empty_count
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ struct Video: Identifiable, Equatable, Hashable {
|
||||
"description": description ?? "",
|
||||
"genre": genre ?? "",
|
||||
"channel": channel.json.object,
|
||||
"thumbnails": thumbnails.compactMap(\.json.object),
|
||||
"thumbnails": thumbnails.compactMap { $0.json.object },
|
||||
"indexID": indexID ?? "",
|
||||
"live": live,
|
||||
"upcoming": upcoming,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</div>
|
||||
|
||||
## Features
|
||||
* Native user interface built with [SwiftUI](https://developer.apple.com/swiftui/) with customization settings
|
||||
* Native user interface built with [SwiftUI](https://developer.apple.com/xcode/swiftui/) with customization settings
|
||||
* Player queue and history
|
||||
* Player component with custom controls, gestures and support for 4K playback
|
||||
* Fullscreen, Picture in Picture and background audio playback
|
||||
|
||||
@@ -57,7 +57,7 @@ final class URLParserTests: XCTestCase {
|
||||
]
|
||||
|
||||
func testUrlsParsing() throws {
|
||||
for urlString in Self.urls {
|
||||
Self.urls.forEach { urlString in
|
||||
let url = URL(string: urlString)!
|
||||
let parser = URLParser(url: url)
|
||||
XCTAssertEqual(parser.destination, .fileURL)
|
||||
@@ -66,7 +66,7 @@ final class URLParserTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testVideosParsing() throws {
|
||||
for (url, id) in Self.videos {
|
||||
Self.videos.forEach { url, id in
|
||||
let parser = URLParser(url: URL(string: url)!)
|
||||
XCTAssertEqual(parser.destination, .video)
|
||||
XCTAssertEqual(parser.videoID, id)
|
||||
@@ -74,7 +74,7 @@ final class URLParserTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testChannelsByNameParsing() throws {
|
||||
for (url, name) in Self.channelsByName {
|
||||
Self.channelsByName.forEach { url, name in
|
||||
let parser = URLParser(url: URL(string: url)!)
|
||||
XCTAssertEqual(parser.destination, .channel)
|
||||
XCTAssertEqual(parser.channelName, name)
|
||||
@@ -83,7 +83,7 @@ final class URLParserTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testChannelsByIdParsing() throws {
|
||||
for (url, id) in Self.channelsByID {
|
||||
Self.channelsByID.forEach { url, id in
|
||||
let parser = URLParser(url: URL(string: url)!)
|
||||
XCTAssertEqual(parser.destination, .channel)
|
||||
XCTAssertEqual(parser.channelID, id)
|
||||
@@ -92,7 +92,7 @@ final class URLParserTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testUsersParsing() throws {
|
||||
for (url, user) in Self.users {
|
||||
Self.users.forEach { url, user in
|
||||
let parser = URLParser(url: URL(string: url)!)
|
||||
XCTAssertEqual(parser.destination, .channel)
|
||||
XCTAssertNil(parser.channelID)
|
||||
@@ -102,7 +102,7 @@ final class URLParserTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testPlaylistsParsing() throws {
|
||||
for (url, id) in Self.playlists {
|
||||
Self.playlists.forEach { url, id in
|
||||
let parser = URLParser(url: URL(string: url)!)
|
||||
XCTAssertEqual(parser.destination, .playlist)
|
||||
XCTAssertEqual(parser.playlistID, id)
|
||||
@@ -110,7 +110,7 @@ final class URLParserTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testSearchesParsing() throws {
|
||||
for (url, query) in Self.searches {
|
||||
Self.searches.forEach { url, query in
|
||||
let parser = URLParser(url: URL(string: url)!)
|
||||
XCTAssertEqual(parser.destination, .search)
|
||||
XCTAssertEqual(parser.searchQuery, query)
|
||||
@@ -127,7 +127,7 @@ final class URLParserTests: XCTestCase {
|
||||
"watch?v=IUTGFQpKaPU&t=30s": 30
|
||||
]
|
||||
|
||||
for (url, time) in samples {
|
||||
samples.forEach { url, time in
|
||||
XCTAssertEqual(
|
||||
URLParser(url: URL(string: url)!).time,
|
||||
time
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Invidious_512x512@1x.png",
|
||||
"filename" : "Invidious.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "Invidious_512x512@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "Invidious_512x512@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
|
||||
2
Shared/Assets.xcassets/Invidious.imageset/Invidious.svg
vendored
Normal file
2
Shared/Assets.xcassets/Invidious.imageset/Invidious.svg
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="512pt" height="512pt" version="1.0" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><g><rect x="-.0072516" y=".00056299" width="512.01" height="512.02" fill="#575757" stroke-width=".063019"/><path d="m247.17 455.95c-19.792-0.78921-38.719-4.2564-57.154-10.47-60.968-20.55-108.68-68.579-127-127.86-7.8955-25.538-10.062-53.943-6.2586-82.067 3.7105-27.439 13.603-53.515 29.342-77.344 12.069-18.273 29.138-36.277 47.228-49.816 36.891-27.61 85.944-42.49 132.38-40.157 25.88 1.3001 49.939 6.765 73.106 16.606 8.1948 3.481 20.024 9.6845 27.696 14.525 14.15 8.9272 22.367 15.498 34.482 27.573 13.254 13.211 22.128 24.276 30.398 37.906 7.2081 11.879 14.099 27.15 18.229 40.397 1.5996 5.1305 4.442 16.456 5.6852 22.653 2.3908 11.917 2.6998 15.722 2.7049 33.312 6e-3 18.515-0.46256 24.413-2.9166 36.758-9.3274 46.92-35.58 88.167-74.872 117.64-22.814 17.112-50.027 29.535-78.547 35.858-16.714 3.7059-35.421 5.2453-54.498 4.4846zm-35.1-78.786c-5.3e-4 -0.52647-0.0741-2.0564-0.16311-3.3999l-0.16178-2.4427-4.7018-0.26271c-4.0477-0.22614-4.7968-0.33363-5.3847-0.77253-2.0235-1.5108-1.4679-6.0695 2.2494-18.457 0.8637-2.8781 3.3371-11.321 5.4966-18.762 2.1594-7.4409 5.2002-17.836 6.7573-23.101 1.5571-5.2648 4.1948-14.282 5.8615-20.038 1.6667-5.7562 3.6145-12.4 4.3284-14.764 0.71391-2.3641 3.2583-11.037 5.6542-19.272 4.9475-17.007 8.1626-27.723 8.9438-29.811 0.51852-1.3858 0.54785-1.4139 0.99761-0.95317 0.25486 0.26106 3.8462 7.3667 7.9807 15.79 4.1345 8.4236 13.089 26.573 19.898 40.331 17.188 34.73 37.849 76.578 43.261 87.622l4.5356 9.257 11.359-0.0895c6.2475-0.0492 11.615-0.19623 11.929-0.32672 0.5614-0.23385 0.54167-0.2959-1.3723-4.3176-1.068-2.2442-8.1436-16.601-15.724-31.904-48.687-98.293-61.22-123.86-67.889-138.48-4.7022-10.309-6.9031-14.807-7.7139-15.762-0.82931-0.97742-1.6319-1.0638-2.3704-0.25525-1.1993 1.313-4.1046 10.063-9.3869 28.27-2.0569 7.0899-6.5372 22.425-9.9562 34.077-6.6396 22.629-8.5182 29.037-14.33 48.883-2.0354 6.9495-4.7977 16.369-6.1385 20.931-1.3408 4.5628-4.033 13.81-5.9826 20.549-4.304 14.877-6.136 20.889-7.3886 24.25-2.1371 5.7334-2.5723 6.3292-4.9216 6.7384-0.88855 0.15472-2.4102 0.28196-3.3815 0.28275-2.1993 3e-3 -3.5494 0.36339-4.0558 1.0863-0.42176 0.60215-0.56421 4.8802-0.18251 5.4812 0.20573 0.32388 2.4672 0.37414 23.34 0.51873l8.6151 0.0597-7e-4 -0.95723zm36.751-205.59c4.3282-0.92335 8.4607-4.943 9.4374-9.1796 0.36569-1.5862 0.32543-4.9758-0.077-6.4799-0.85108-3.1813-3.2688-6.291-6.039-7.7675-3.8111-2.0313-9.456-2.0295-13.272 5e-3 -5.9828 3.1888-8.1556 11.089-4.7878 17.408 2.6995 5.0648 8.3611 7.3754 14.738 6.015z" fill="#f0f0f0" stroke-width=".025526"/></g><g transform="matrix(.069892 0 0 -.069892 44.236 474.48)"><path d="m2787 4669c-124-65-123-255 3-319 86-44 196-16 247 62 58 87 26 211-67 258-51 26-132 26-183-1z" fill="#00b6f0" stroke="#00b6f0" stroke-width="4.25"/><path d="m2882 4108c-12-16-63-166-102-303-30-104-101-350-165-565-20-69-58-199-85-290-26-91-64-221-85-290-20-69-58-199-85-290-26-91-64-221-85-290-20-69-57-195-81-280-59-207-93-299-115-310-10-6-35-10-56-10-73 0-84-8-81-54l3-41 228-3 228-2-3 47-3 48-73 3c-66 3-74 5-84 27-13 28 0 104 37 225 13 41 47 156 75 255s66 230 85 290c18 61 56 191 85 290 28 99 66 230 85 290 18 61 56 191 85 290 85 297 123 419 131 429 5 5 17-11 28-35 10-24 192-393 403-819s447-902 523-1058l139-282h168c92 0 168 4 168 8s-75 158-166 342c-588 1183-969 1958-1033 2100-29 63-69 151-89 195-44 95-58 110-80 83z" fill="#575757"/></g></svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 37 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 146 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user