Compare commits

..

75 Commits

Author SHA1 Message Date
Arkadiusz Fal
d344ab15dc Bump build number 2022-05-29 22:52:29 +02:00
Arkadiusz Fal
888813f095 Minor fixes 2022-05-29 22:51:52 +02:00
Arkadiusz Fal
a0a6020ee6 Add close video button to browser controls 2022-05-29 22:20:38 +02:00
Arkadiusz Fal
e3e68cd158 Minor improvements 2022-05-29 22:20:38 +02:00
Arkadiusz Fal
42e56d6e4b Navigation improvements 2022-05-29 22:20:38 +02:00
Arkadiusz Fal
f979af7f01 New playlist navigation 2022-05-29 22:20:38 +02:00
Arkadiusz Fal
124a48812a New channel navigation 2022-05-29 22:20:38 +02:00
Arkadiusz Fal
44e6c28fd4 Add buttons to next video and restart video (fix #106)
Previous video requires rebuilding queue a little, maybe in the future
2022-05-29 22:20:38 +02:00
Arkadiusz Fal
4f6e0e2a3d Don't push MPV to play HLS on changing backends
It takes longer to load than WEBM
2022-05-29 22:20:38 +02:00
Arkadiusz Fal
ccc1cc89ad Add Open in PiP option (fix #137) 2022-05-29 22:20:38 +02:00
Arkadiusz Fal
5d0eb2478c Fix playing video from start when history disabled 2022-05-29 21:21:29 +02:00
Arkadiusz Fal
c28429ec7f Minor improvements 2022-05-29 21:21:29 +02:00
Arkadiusz Fal
6c3a7882e1 Player animation improvements 2022-05-29 21:21:29 +02:00
Arkadiusz Fal
76286e8a45 Fix orientation (#121) 2022-05-29 21:21:29 +02:00
Arkadiusz Fal
c5128f6227 Minor improvements 2022-05-29 21:21:29 +02:00
Arkadiusz Fal
4bf171637b Player overlaying other views and swipe gesture (fix #44, #130) 2022-05-29 21:21:29 +02:00
Arkadiusz Fal
249adab427 Remove some default Favorites 2022-05-29 21:21:29 +02:00
Arkadiusz Fal
a27ebcce27 More controls improvements 2022-05-29 21:21:29 +02:00
Arkadiusz Fal
b5f3a1bd09 Minor player controls improvements (fix #94) 2022-05-29 21:21:17 +02:00
Arkadiusz Fal
b306819af9 Update build and version for TestFlight 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
aa29688b4c Bump build and version number 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
371e6a275b Fix browser controls play button disabled state 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
0b7e9f8c47 PiP improvements 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
680fe915e1 Don't skip segments that start before 4 seconds
To minimize buffering
2022-05-27 23:05:09 +02:00
Arkadiusz Fal
1e7ebe4e68 Fix #126 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
1a230d36f7 Improve stream control on macOS 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
60e1cef84f Prefer VP9/WEBM over H.264/MP4 (fix #128) 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
1150a01496 Add PiP for iOS 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
e5c9e11b17 Minor improvements to controls 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
fb3d64bcc7 Fix rate button 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
dec670b7b7 Lint 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
ea4dc2358b Add resolution 8K 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
fca7b7a1a7 Run play action async 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
ea0db9533a Fix using Watch history in player queue 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
db85be76f7 Throttle SponsorBlock seek 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
5cb2e1b8f0 Add rate change selector 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
387f29e395 Restore last played item into queue only if it's not in there yet 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
9ae8c85f4e Improve subscriptions count
Piped API now includes it in the streams response, no need for separate
query
2022-05-27 23:05:09 +02:00
Arkadiusz Fal
c94a35d2c6 Add resolutions for 50fps and 48fps (fix #120) 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
aacbd7889c Add hd2160p60fps resolution (fix #118) 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
d8020d06dd Fix player size handling 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
c556f0e21d Minor fix 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
ff1f62b6ad Bump build and version number 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
7cb4e0dccf Fix #86 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
3229e3151f Improve EOF handling 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
53103c9ecf Try to patch #78
Issue appears when app switches layout from tab to sidebar navigation
2022-05-27 23:05:09 +02:00
Arkadiusz Fal
ac53deb39b Limit formats available to AVPlayer 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
cab6f486ba Fullscreen handling changes 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
40e16fcc7e Remove redunant update of player size 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
9c687f8704 Fix project settings 2022-05-27 23:05:09 +02:00
Arkadiusz Fal
340ceff131 Improve keyboard shortcuts 2022-05-27 23:05:08 +02:00
Arkadiusz Fal
76a116659e Minor fixes 2022-05-27 23:05:08 +02:00
Arkadiusz Fal
7c009080a5 Fix optional 2022-05-27 23:05:08 +02:00
Arkadiusz Fal
61d7c53a58 Bump build and version number 2022-05-27 23:05:08 +02:00
Arkadiusz Fal
08e65aff0f Controls fixes 2022-05-27 23:05:08 +02:00
Arkadiusz Fal
722616e3d0 tvOS fixes 2022-05-27 23:05:08 +02:00
Arkadiusz Fal
0826f01150 Close fullscreen and restore portrait on closing player 2022-05-27 23:05:08 +02:00
Arkadiusz Fal
fcb9ec1f6a Improve streams quality settings 2022-05-27 23:05:08 +02:00
Arkadiusz Fal
5bb1589159 Add tvOS mpv libraries 2022-05-27 23:05:08 +02:00
Arkadiusz Fal
60e5cc5d75 Fix player window on Mac 2022-05-27 23:05:07 +02:00
Arkadiusz Fal
bc410ad6ba Minor improvements 2022-05-27 23:05:07 +02:00
Arkadiusz Fal
35c358de6b Bump version number 2022-05-27 23:05:07 +02:00
Arkadiusz Fal
23955699dc Add toggle for dislikes 2022-05-27 23:05:07 +02:00
Arkadiusz Fal
742103e4c2 Bump version number 2022-05-27 23:05:07 +02:00
Arkadiusz Fal
6173f4610b Minor fixes 2022-05-27 23:05:07 +02:00
Arkadiusz Fal
1217acf264 Add ReturnYoutubeDislike API 2022-05-27 23:05:07 +02:00
Arkadiusz Fal
0b8d887cef Fixes for MPV in macOS 2022-05-27 23:05:07 +02:00
Arkadiusz Fal
b760f172d0 Fix EOF handler 2022-05-27 23:05:07 +02:00
Arkadiusz Fal
bb8fc78760 Minor improvements 2022-05-27 23:05:07 +02:00
Arkadiusz Fal
d2317f725f Add hide player button cancel action 2022-05-27 23:05:07 +02:00
Arkadiusz Fal
693c3364b0 Prevent multiple seeks 2022-05-27 23:05:07 +02:00
Arkadiusz Fal
18bfc1abc9 Add Now Playing info center updates 2022-05-27 23:05:07 +02:00
Arkadiusz Fal
717830ee99 Hello, mpv! 🎉 2022-05-27 23:05:07 +02:00
Arkadiusz Fal
2b001ec96c Reorganize toolbars placement 2022-05-27 23:04:20 +02:00
Arkadiusz Fal
ebeb5bc520 Update version and iOS Info.plist for Testflight 2022-05-27 23:04:20 +02:00
506 changed files with 12574 additions and 52409 deletions

5
.github/FUNDING.yml vendored
View File

@@ -1,5 +0,0 @@
# These are supported funding model platforms
github: yattee
patreon: arekf
custom: https://github.com/yattee/yattee/wiki/Donations

View File

@@ -1,74 +0,0 @@
name: Bug Report
description: Report a bug or unexpected behavior
labels: "bug"
body:
- type: markdown
attributes:
value: |
**Before You Submit Your Issue**
- This is not a right place to ask questions. Use [Discussions](https://github.com/yattee/yattee/discussions) or contact directly via [Discord](https://yattee.stream/discord) or [Matrix Channel](https://matrix.to/#/#Yattee:matrix.org)
- type: checkboxes
attributes:
label: Guidelines
description: Please ensure you've completed the following
options:
- label: I have searched the [issue tracker](https://github.com/yattee/yattee/issues) and I haven't found bug report like this
required: true
- type: textarea
attributes:
label: Current Behavior
description: Describe what happened and the steps you have done to encounter the problem
validations:
required: true
- type: textarea
attributes:
label: Expected Behavior
description: Describe what you expected to happen
validations:
required: true
- type: input
attributes:
label: Device & OS Version
description: What device and operating system version are you running? You can find it in device settings
placeholder: e.g. iPhone, iOS 16.1
validations:
required: true
- type: input
attributes:
label: App Build
description: What build of the application are you running? You can find this in the bottom of the Settings page
placeholder: e.g. 112
validations:
required: true
- type: textarea
attributes:
label: App Settings
description: If the issue is related to some non-default configuration, please provide the settings you have changed
validations:
required: false
- type: textarea
attributes:
label: Crash log
description: If you are reporing a crash, attach crash log here
placeholder: |
1. Go to 'Settings'
2. Click on 'Privacy'
3. Scroll down and open 'Analytics & Improvements'
4. Go to 'Analytics Data'
4. See crash log
validations:
required: false
- type: textarea
attributes:
label: Screenshots, Videos and other files
description: Submit screenshots, video of the issue or other files that can help to understand the problem better. If you are reporting issue with playback in MPV, attach [logs from MPV](https://github.com/yattee/yattee/wiki/Getting-MPV-logs)
validations:
required: false

View File

@@ -1 +0,0 @@
blank_issues_enabled: true

View File

@@ -1,39 +0,0 @@
name: Feature request
description: Suggest an new feature or an idea to improve the existing one
labels: "enhancement"
body:
- type: markdown
attributes:
value: |
**Before You Submit Your Issue**
- This is not a right place to ask questions. Use [Discussions](https://github.com/yattee/yattee/discussions) or contact directly via [Discord](https://yattee.stream/discord) or [Matrix Channel](https://matrix.to/#/#Yattee:matrix.org)
- type: checkboxes
attributes:
label: Guidelines
description: Please ensure you've completed the following
options:
- label: I have searched the [issue tracker](https://github.com/yattee/yattee/issues) and I haven't found feature request like this
required: true
- type: dropdown
attributes:
label: Type
description: Please select a type that fits this request. Choose multiple options, if applicable
multiple: true
options:
- New feature
- Improvement to existing feature
- Usability improvement
- Visual improvement
- Other
validations:
required: true
- type: textarea
attributes:
label: Describe the feature
description: Provide a description of the feature that you're requesting to have implemented. You can attach screenshots or mockups to help to understand it better
placeholder: The feature should work like this...
validations:
required: true

View File

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

View File

@@ -1,35 +0,0 @@
name: Bump build number
on:
workflow_dispatch:
env:
APP_NAME: Yattee
jobs:
bump_build:
name: Bump build number
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- 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'
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
with:
token: ${{ secrets.GIT_AUTHORIZATION }}
branch: actions/bump-build-to-${{ env.BUILD_NUMBER }}
base: main
title: Bump build number to ${{ env.BUILD_NUMBER }}

View File

@@ -1,108 +0,0 @@
name: Build and release to TestFlight and GitHub
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 }}
TESTFLIGHT_EXTERNAL_GROUPS: ${{ secrets.TESTFLIGHT_EXTERNAL_GROUPS }}
jobs:
testflight:
strategy:
matrix:
# disabled mac beta lane
# lane: ['mac beta', 'ios beta', 'tvos beta']
lane: ['ios beta', 'tvos beta']
name: Releasing ${{ matrix.lane }} version to TestFlight
runs-on: macos-latest
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 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
with:
name: ${{ matrix.lane }} build
path: fastlane/builds/**/*.ipa
if-no-files-found: ignore
mac_notarized:
name: Build and notarize macOS app
runs-on: macos-latest
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: '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
- 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
release:
needs: ['testflight', 'mac_notarized']
name: Create GitHub release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- 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
with:
path: artifacts
- uses: ncipollo/release-action@v1
with:
artifacts: artifacts/**/*.ipa,artifacts/**/*.zip
commit: main
tag: ${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}
prerelease: true
bodyFile: CHANGELOG.md

11
.gitignore vendored
View File

@@ -81,9 +81,6 @@ fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output
fastlane/builds
fastlane/.env
fastlane/*.p8
# Code Injection
#
@@ -95,9 +92,5 @@ iOSInjectionProject/
# SwiftLint Remote Config Cache
.swiftlint/RemoteConfigCache
# User-specific xcconfig files
Xcode-config/DEVELOPMENT_TEAM.xcconfig
# Bundler
.bundle/
Vendor/bundle/
# disable simulator libraries - to be removed when replaced with framework for mpv
Vendor/mpv/iOS/lib_sim

View File

@@ -1,21 +1,10 @@
parent_config: https://raw.githubusercontent.com/sindresorhus/swiftlint-config/main/.swiftlint.yml
disabled_rules:
- conditional_returns_on_newline
- identifier_name
- opening_brace
- 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

View File

@@ -1,16 +1,16 @@
import SwiftUI
extension Backport where Content: View {
@ViewBuilder func badge(_ count: Text?) -> some View {
#if os(tvOS)
content
#else
// swiftlint:disable:next deployment_target
if #available(iOS 15.0, macOS 12.0, *) {
content.badge(count)
} else {
@ViewBuilder func badge(_ count: Text) -> some View {
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
content.badge(count)
} else {
HStack {
content
Spacer()
Text("\(count)")
.foregroundColor(.secondary)
}
#endif
}
}
}

View File

@@ -1,18 +0,0 @@
import Foundation
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
content
#endif
}
}

View File

@@ -1,12 +0,0 @@
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 {
content
}
}
}

View File

@@ -1,12 +0,0 @@
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 {
content
}
}
}

View File

@@ -1,13 +0,0 @@
import Foundation
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 {
content
}
}
}

View File

@@ -1,22 +0,0 @@
import Foundation
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 {
content
}
}
@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 {
content
}
}
}

View File

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

View File

@@ -1,23 +0,0 @@
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, *) {
content
.toolbarBackground(color, for: .navigationBar)
} else {
content
}
}
@ViewBuilder func toolbarBackgroundVisibility(_ visible: Bool) -> some View {
// swiftlint:disable:next deployment_target
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) {
content
.toolbarBackground(visible ? .visible : .hidden, for: .navigationBar)
} else {
content
}
}
}

View File

@@ -1,13 +0,0 @@
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, *) {
content
.toolbarColorScheme(colorScheme, for: .navigationBar)
} else {
content
}
}
}

View File

@@ -76,7 +76,7 @@ import SwiftUI
let blurEffect = UIBlurEffect(style: blurStyle)
blurView.effect = blurEffect
if let vibrancyStyle {
if let vibrancyStyle = vibrancyStyle {
vibrancyView.effect = UIVibrancyEffect(blurEffect: blurEffect, style: vibrancyStyle)
} else {
vibrancyView.effect = nil

View File

@@ -1,50 +0,0 @@
## Build 210
## 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

View File

@@ -1,11 +0,0 @@
import AVKit
extension AVPlayerViewController {
func enterFullScreen(animated: Bool) {
perform(NSSelectorFromString("enterFullScreenAnimated:completionHandler:"), with: animated, with: nil)
}
func exitFullScreen(animated: Bool) {
perform(NSSelectorFromString("exitFullScreenAnimated:completionHandler:"), with: animated, with: nil)
}
}

View File

@@ -1,15 +0,0 @@
import SwiftUI
extension ShapeStyle where Self == Color {
static var debug: Color {
#if DEBUG
return Color(
red: .random(in: 0 ... 1),
green: .random(in: 0 ... 1),
blue: .random(in: 0 ... 1)
)
#else
return Color(.clear)
#endif
}
}

View File

@@ -1,15 +1,15 @@
import Foundation
extension Double {
func formattedAsPlaybackTime(allowZero: Bool = false, forceHours: Bool = false) -> String? {
guard allowZero || !isZero, isFinite else {
func formattedAsPlaybackTime() -> String? {
guard !isZero, isFinite else {
return nil
}
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .positional
formatter.allowedUnits = self >= (60 * 60) || forceHours ? [.hour, .minute, .second] : [.minute, .second]
formatter.allowedUnits = self >= (60 * 60) ? [.hour, .minute, .second] : [.minute, .second]
formatter.zeroFormattingBehavior = [.pad]
return formatter.string(from: self)
@@ -19,9 +19,7 @@ extension Double {
let date = Date(timeIntervalSince1970: self)
let formatter = RelativeDateTimeFormatter()
formatter.dateTimeStyle = .named
formatter.unitsStyle = .short
formatter.formattingContext = .standalone
formatter.unitsStyle = .full
return formatter.localizedString(for: date, relativeTo: Date())
}

View File

@@ -1,14 +0,0 @@
import CoreData
extension NSManagedObjectContext {
/// Executes the given `NSBatchDeleteRequest` and directly merges the changes to bring the given managed object context up to date.
///
/// - Parameter batchDeleteRequest: The `NSBatchDeleteRequest` to execute.
/// - Throws: An error if anything went wrong executing the batch deletion.
func executeAndMergeChanges(_ batchDeleteRequest: NSBatchDeleteRequest) throws {
batchDeleteRequest.resultType = .resultTypeObjectIDs
let result = try execute(batchDeleteRequest) as? NSBatchDeleteResult
let changes: [AnyHashable: Any] = [NSDeletedObjectsKey: result?.result as? [NSManagedObjectID] ?? []]
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [self])
}
}

View File

@@ -1,7 +0,0 @@
extension NSObject {
class func swizzle(origSelector: Selector, withSelector: Selector, forClass: AnyClass) {
let originalMethod = class_getInstanceMethod(forClass, origSelector)
let swizzledMethod = class_getInstanceMethod(forClass, withSelector)
method_exchangeImplementations(originalMethod!, swizzledMethod!)
}
}

View File

@@ -1,5 +0,0 @@
import Foundation
extension Notification.Name {
static let accountConfigurationComplete = Notification.Name("accountConfigurationComplete")
}

View File

@@ -1,8 +0,0 @@
import Foundation
extension Sequence where Iterator.Element: Hashable {
func unique() -> [Iterator.Element] {
var seen: Set<Iterator.Element> = []
return filter { seen.insert($0).inserted }
}
}

View File

@@ -7,29 +7,4 @@ extension String {
}
return replacingCharacters(in: range, with: replacement)
}
func replacingMatches(regex: String, replacementStringClosure: (String) -> String?) -> String {
guard let regex = try? NSRegularExpression(pattern: regex) else {
return self
}
let results = regex.matches(in: self, range: NSRange(startIndex..., in: self))
var outputText = self
for match in results.reversed() {
for rangeIndex in (1 ..< match.numberOfRanges).reversed() {
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
}
let replacement = replacementStringClosure(matchingGroup) ?? matchingGroup
outputText = outputText.replacingOccurrences(of: matchingGroup, with: replacement, range: range)
}
}
return outputText
}
}

View File

@@ -1,7 +0,0 @@
import Foundation
extension String {
func localized(_ comment: String = "") -> Self {
NSLocalizedString(self, tableName: "Localizable", bundle: .main, comment: comment)
}
}

View File

@@ -1,14 +0,0 @@
import Foundation
extension String {
var replacingHTMLEntities: String {
do {
return try NSAttributedString(data: Data(utf8), options: [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue
], documentAttributes: nil).string
} catch {
return self
}
}
}

View File

@@ -1,28 +0,0 @@
import Foundation
import UIKit
extension UIDevice {
/// A Boolean value indicating whether the device has cellular data capabilities (true) or not (false).
var hasCellularCapabilites: Bool {
var addrs: UnsafeMutablePointer<ifaddrs>?
var cursor: UnsafeMutablePointer<ifaddrs>?
defer { freeifaddrs(addrs) }
guard getifaddrs(&addrs) == 0 else { return false }
cursor = addrs
while cursor != nil {
guard
let utf8String = cursor?.pointee.ifa_name,
let name = NSString(utf8String: utf8String),
name == "pdp_ip0"
else {
cursor = cursor?.pointee.ifa_next
continue
}
return true
}
return false
}
}

View File

@@ -1,15 +0,0 @@
import UIKit
extension UIViewController {
@objc var swizzle_prefersHomeIndicatorAutoHidden: Bool {
true
}
public class func swizzleHomeIndicatorProperty() {
swizzle(
origSelector: #selector(getter: UIViewController.prefersHomeIndicatorAutoHidden),
withSelector: #selector(getter: UIViewController.swizzle_prefersHomeIndicatorAutoHidden),
forClass: UIViewController.self
)
}
}

View File

@@ -1,18 +0,0 @@
import Foundation
extension URL {
func byReplacingYatteeProtocol(with urlProtocol: String = "https") -> URL! {
var urlAbsoluteString = absoluteString
guard urlAbsoluteString.hasPrefix(Strings.yatteeProtocol) else {
return self
}
urlAbsoluteString = String(urlAbsoluteString.dropFirst(Strings.yatteeProtocol.count))
if absoluteString.contains("://") {
return URL(string: urlAbsoluteString)
}
return URL(string: "\(urlProtocol)://\(urlAbsoluteString)")
}
}

View File

@@ -19,21 +19,12 @@ extension View {
}
private func verticalEdgeBorder(_ edge: Alignment, height: Double, color: Color) -> some View {
overlay(
Rectangle()
.frame(width: nil, height: height, alignment: .top)
.foregroundColor(color)
.ignoresSafeArea(.all, edges: .horizontal),
alignment: edge
)
overlay(Rectangle().frame(width: nil, height: height, alignment: .top)
.foregroundColor(color), alignment: edge)
}
private func horizontalEdgeBorder(_ edge: Alignment, width: Double, color: Color) -> some View {
overlay(
Rectangle()
.frame(width: width, height: nil, alignment: .leading)
.foregroundColor(color),
alignment: edge
)
overlay(Rectangle().frame(width: width, height: nil, alignment: .leading)
.foregroundColor(color), alignment: edge)
}
}

View File

@@ -3,7 +3,6 @@ import Foundation
extension ChannelPlaylist {
static var fixture: ChannelPlaylist {
ChannelPlaylist(
id: "fixture-channel-playlist",
title: "Playlist with a very long title that will not fit easily in the screen",
thumbnailURL: URL(string: "https://i.ytimg.com/vi/hT_nvWreIhg/hqdefault.jpg?sqp=-oaymwEWCKgBEF5IWvKriqkDCQgBFQAAiEIYAQ==&rs=AOn4CLAAD21_-Bo6Td1z3cV-UFyoi1flEg")!,
channel: Video.fixture.channel,

View File

@@ -12,7 +12,7 @@ extension Comment {
likeCount: 30032,
text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut luctus feugiat mi, suscipit pharetra lectus dapibus vel. Vivamus orci erat, sagittis sit amet dui vel, feugiat cursus ante. Pellentesque eget orci tortor. Suspendisse pulvinar orci tortor, eu scelerisque neque consequat nec. Aliquam sit amet turpis et nunc placerat finibus eget sit amet justo. Nullam tincidunt ornare neque. Donec ornare, arcu at elementum pulvinar, urna elit pharetra diam, vel ultrices lacus diam at lorem. Sed vel maximus dolor. Morbi massa est, interdum quis justo sit amet, dapibus bibendum tellus. Integer at purus nec neque tincidunt convallis sit amet eu odio. Duis et ante vitae sem tincidunt facilisis sit amet ac mauris. Quisque varius non nisi vel placerat. Nulla orci metus, imperdiet ac accumsan sed, pellentesque eget nisl. Praesent a suscipit lacus, ut finibus orci. Nulla ut eros commodo, fermentum purus at, porta leo. In finibus luctus nulla, eget posuere eros mollis vel. ",
repliesPage: "some url",
channel: .init(app: .invidious, id: "", name: "")
channel: .init(id: "", name: "")
)
}
}

View File

@@ -2,6 +2,6 @@ import Foundation
extension Instance {
static var fixture: Instance {
Instance(app: .invidious, name: "Home", apiURLString: "https://invidious.home.net")
Instance(app: .invidious, name: "Home", apiURL: "https://invidious.home.net")
}
}

View File

@@ -5,12 +5,9 @@ extension Video {
static var fixtureChannelID: Channel.ID = "channel-fixture"
static var fixture: Video {
let bannerURL = "https://yt3.ggpht.com/SQiRareBDrV2Z6A30HSD0iUABOGysanmKLtaJq7lJ_ME-MtoLb3O61QdlJfH2KhSOA0eKPr_=w2560-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj"
let thumbnailURL = "https://yt3.ggpht.com/ytc/AKedOLR-pT_JEsz_hcaA4Gjx8DHcqJ8mS42aTRqcVy6P7w=s88-c-k-c0x00ffffff-no-rj-mo"
let chapterImageURL = URL(string: "https://pipedproxy.kavin.rocks/vi/rr2XfL_df3o/hqdefault_29633.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg%3D%3D&rs=AOn4CLDFDm9D5SvsIA7D3v5n5KZahLs_UA&host=i.ytimg.com")!
return Video(
app: .invidious,
videoID: fixtureID,
title: "Relaxing Piano Music to feel good",
author: "Fancy Videotuber",
@@ -20,14 +17,10 @@ extension Video {
description: "Some relaxing live piano music",
genre: "Music",
channel: Channel(
app: .invidious,
id: fixtureChannelID,
name: "The Channel",
bannerURL: URL(string: bannerURL)!,
thumbnailURL: URL(string: thumbnailURL)!,
description: "The best channel that ever existed.\nThe best channel that ever existed. The best channel that ever existed. The best channel that ever existed. The best channel that ever existed. ",
subscriptionsCount: 2300,
totalViews: 3_260_378_817,
videos: []
),
thumbnails: [],
@@ -36,53 +29,7 @@ extension Video {
publishedAt: Date(),
likes: 37333,
dislikes: 30,
keywords: ["very", "cool", "video", "msfs 2020", "757", "747", "A380", "737-900", "MOD", "Zibo", "MD80", "MD11", "Rotate", "Laminar", "787", "A350", "MSFS", "MS2020", "Microsoft Flight Simulator", "Microsoft", "Flight", "Simulator", "SIM", "World", "Ortho", "Flying", "Boeing MAX"],
related: [.otherFixture],
chapters: [
.init(title: "A good chapter name", image: chapterImageURL, start: 20),
.init(title: "Other fine but incredibly too long chapter name, I don't know what else to say", image: chapterImageURL, start: 30),
.init(title: "Short", image: chapterImageURL, start: 60)
]
)
}
static var otherFixture: Video {
let bannerURL = "https://yt3.ggpht.com/SQiRareBDrV2Z6A30HSD0iUABOGysanmKLtaJq7lJ_ME-MtoLb3O61QdlJfH2KhSOA0eKPr_=w2560-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj"
let thumbnailURL = "https://yt3.ggpht.com/ytc/AKedOLR-pT_JEsz_hcaA4Gjx8DHcqJ8mS42aTRqcVy6P7w=s88-c-k-c0x00ffffff-no-rj-mo"
let chapterImageURL = URL(string: "https://pipedproxy.kavin.rocks/vi/rr2XfL_df3o/hqdefault_29633.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg%3D%3D&rs=AOn4CLDFDm9D5SvsIA7D3v5n5KZahLs_UA&host=i.ytimg.com")!
return Video(
app: .invidious,
videoID: fixtureID + fixtureID,
title: "Relaxing Piano Music to feel good",
author: "Fancy Videotuber",
length: 582,
published: "7 years ago",
views: 21534,
description: "Some relaxing live piano music",
genre: "Music",
channel: Channel(
app: .invidious,
id: fixtureChannelID + fixtureChannelID,
name: "The Channel",
bannerURL: URL(string: bannerURL)!,
thumbnailURL: URL(string: thumbnailURL)!,
subscriptionsCount: 2300,
totalViews: 3_260_378_817,
videos: []
),
thumbnails: [],
live: false,
upcoming: false,
publishedAt: Date(),
likes: 37333,
dislikes: 30,
keywords: ["very", "cool", "video", "msfs 2020", "757", "747", "A380", "737-900", "MOD", "Zibo", "MD80", "MD11", "Rotate", "Laminar", "787", "A350", "MSFS", "MS2020", "Microsoft Flight Simulator", "Microsoft", "Flight", "Simulator", "SIM", "World", "Ortho", "Flying", "Boeing MAX"],
chapters: [
.init(title: "A good chapter name", image: chapterImageURL, start: 20),
.init(title: "Other fine but incredibly too long chapter name, I don't know what else to say", image: chapterImageURL, start: 30),
.init(title: "Short", image: chapterImageURL, start: 60)
]
keywords: ["very", "cool", "video", "msfs 2020", "757", "747", "A380", "737-900", "MOD", "Zibo", "MD80", "MD11", "Rotate", "Laminar", "787", "A350", "MSFS", "MS2020", "Microsoft Flight Simulator", "Microsoft", "Flight", "Simulator", "SIM", "World", "Ortho", "Flying", "Boeing MAX"]
)
}

View File

@@ -4,6 +4,44 @@ import SwiftUI
struct FixtureEnvironmentObjectsModifier: ViewModifier {
func body(content: Content) -> some View {
content
.environmentObject(AccountsModel())
.environmentObject(CommentsModel())
.environmentObject(InstancesModel())
.environmentObject(invidious)
.environmentObject(NavigationModel())
.environmentObject(PipedAPI())
.environmentObject(player)
.environmentObject(PlaylistsModel())
.environmentObject(RecentsModel())
.environmentObject(SearchModel())
.environmentObject(subscriptions)
.environmentObject(ThumbnailsModel())
}
private var invidious: InvidiousAPI {
let api = InvidiousAPI()
api.validInstance = true
api.signedIn = true
return api
}
private var player: PlayerModel {
let player = PlayerModel()
player.currentItem = PlayerQueueItem(Video.fixture)
player.queue = Video.allFixtures.map { PlayerQueueItem($0) }
return player
}
private var subscriptions: SubscriptionsModel {
let subscriptions = SubscriptionsModel()
subscriptions.channels = Video.allFixtures.map { $0.channel }
return subscriptions
}
}

View File

@@ -1,6 +0,0 @@
source "https://rubygems.org"
gem 'fastlane'
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)

View File

@@ -1,231 +0,0 @@
GEM
remote: https://rubygems.org/
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)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
bundler (>= 1.12.0, < 3.0.0)
colored (~> 1.2)
commander (~> 4.6)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
faraday (~> 1.0)
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)
google-cloud-env (>= 1.6.0, < 2.0.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
naturally (~> 2.2)
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)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 3)
tty-screen (>= 0.6.3, < 1.0.0)
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-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.3)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0)
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
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-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-storage (1.47.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.31.0)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.8)
domain_name (~> 0.5)
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.16.0)
jwt (2.10.2)
base64
logger (1.7.0)
mini_magick (4.13.2)
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)
os (1.1.4)
plist (3.7.2)
public_suffix (6.0.2)
rake (13.3.1)
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)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
security (0.1.5)
signet (0.21.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.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)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
xcodeproj (1.27.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)
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
BUNDLED WITH
2.5.22

0
ISSUE_TEMPLATE.md Normal file
View File

View File

@@ -5,83 +5,53 @@ struct Account: Defaults.Serializable, Hashable, Identifiable {
static var bridge = AccountsBridge()
let id: String
var app: VideosApp?
let instanceID: String?
var name: String
let urlString: String
var username: String
var password: String?
let instanceID: String
var name: String?
let url: String
let username: String
let password: String?
var token: String?
let anonymous: Bool
let country: String?
let region: String?
init(
id: String? = nil,
app: VideosApp? = nil,
instanceID: String? = nil,
name: String? = nil,
urlString: String? = nil,
url: String? = nil,
username: String? = nil,
password: String? = nil,
anonymous: Bool = false,
country: String? = nil,
region: String? = nil
token: String? = nil,
anonymous: Bool = false
) {
self.anonymous = anonymous
self.id = id ?? (anonymous ? "anonymous-\(instanceID ?? urlString ?? UUID().uuidString)" : UUID().uuidString)
self.instanceID = instanceID
self.name = name ?? ""
self.urlString = urlString ?? ""
self.id = id ?? (anonymous ? "anonymous-\(instanceID!)" : UUID().uuidString)
self.instanceID = instanceID ?? UUID().uuidString
self.name = name
self.url = url ?? ""
self.username = username ?? ""
self.token = token
self.password = password ?? ""
self.country = country
self.region = region
self.app = app ?? instance.app
}
var url: URL! {
URL(string: urlString)
}
var token: String? {
KeychainModel.shared.getAccountKey(self, "token")
}
var credentials: (String?, String?) {
AccountsModel.getCredentials(self)
}
var instance: Instance! {
InstancesModel.shared.find(instanceID) ?? Instance(app: app ?? .invidious, name: urlString, apiURLString: urlString)
Defaults[.instances].first { $0.id == instanceID }
}
var isPublic: Bool {
instanceID.isNil
}
var shortUsername: String {
guard username.count > 10 else {
return username
}
var isPublicAddedToCustom: Bool {
InstancesModel.shared.findByURLString(urlString) != nil
let index = username.index(username.startIndex, offsetBy: 11)
return String(username[..<index])
}
var description: String {
guard !isPublic else {
return name
}
let (username, _) = credentials
return username ?? name
}
var urlHost: String {
URLComponents(url: url, resolvingAgainstBaseURL: false)?.host ?? ""
(name != nil && name!.isEmpty) ? shortUsername : name!
}
func hash(into hasher: inout Hasher) {
hasher.combine(username)
}
var feedCacheKey: String {
"feed-\(id)"
}
}

View File

@@ -1,10 +1,9 @@
import Alamofire
import Foundation
import Siesta
import SwiftUI
final class AccountValidator: Service {
let app: Binding<VideosApp?>
let app: Binding<VideosApp>
let url: String
let account: Account!
@@ -14,10 +13,8 @@ final class AccountValidator: Service {
var isValidating: Binding<Bool>
var error: Binding<String?>?
private var appsToValidateInstance = VideosApp.allCases
init(
app: Binding<VideosApp?>,
app: Binding<VideosApp>,
url: String,
account: Account? = nil,
id: Binding<String>,
@@ -44,217 +41,113 @@ final class AccountValidator: Service {
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
}
configure("/api/v1/auth/feed", requestMethods: [.get]) {
guard self.account != nil else {
return
}
$0.headers["Cookie"] = self.invidiousCookieHeader
}
configure("/login", requestMethods: [.post]) {
$0.headers["Content-Type"] = "application/json"
}
}
func instanceValidationResource(_ app: VideosApp) -> Resource {
switch app {
case .invidious:
return resource("/api/v1/videos/dQw4w9WgXcQ")
case .piped:
return resource("/streams/dQw4w9WgXcQ")
case .peerTube:
// TODO: fixme
return resource("")
case .local:
return resource("")
}
}
func validateInstance() {
reset()
guard let app = appsToValidateInstance.popLast() else { return }
tryValidatingUsing(app)
}
func tryValidatingUsing(_ app: VideosApp) {
instanceValidationResource(app)
neverGonnaGiveYouUp
.load()
.onSuccess { response in
guard self.url == self.formObjectID.wrappedValue else {
return
}
guard !response.json.isEmpty else {
if app == .piped {
if response.text.contains("property=\"og:title\" content=\"Piped\"") {
self.isValid.wrappedValue = false
self.isValidated.wrappedValue = true
self.isValidating.wrappedValue = false
self.error?.wrappedValue = "Trying to use Piped front-end URL, you need to use URL for Piped API instead"
return
}
}
guard let nextApp = self.appsToValidateInstance.popLast() else {
self.isValid.wrappedValue = false
self.isValidated.wrappedValue = true
self.isValidating.wrappedValue = false
return
}
self.tryValidatingUsing(nextApp)
return
}
let json = response.json.dictionaryValue
let author = app == .invidious ? json["author"] : json["uploader"]
let author = self.app.wrappedValue == .invidious ? json["author"] : json["uploader"]
if author == "Rick Astley" {
self.app.wrappedValue = app
self.isValid.wrappedValue = true
self.error?.wrappedValue = nil
} else {
self.isValid.wrappedValue = false
}
self.isValidated.wrappedValue = true
self.isValidating.wrappedValue = false
}
.onFailure { error in
guard self.url == self.formObjectID.wrappedValue else {
return
}
if self.appsToValidateInstance.isEmpty {
self.isValidating.wrappedValue = false
self.isValidated.wrappedValue = true
self.isValid.wrappedValue = false
self.error?.wrappedValue = error.userMessage
} else {
guard let app = self.appsToValidateInstance.popLast() else { return }
self.tryValidatingUsing(app)
}
self.isValid.wrappedValue = false
self.error?.wrappedValue = error.userMessage
}
.onCompletion { _ in
self.isValidated.wrappedValue = true
self.isValidating.wrappedValue = false
}
}
func validateAccount() {
reset()
switch app.wrappedValue {
case .invidious:
validateInvidiousAccount()
case .piped:
validatePipedAccount()
default:
setValidationResult(false)
}
}
func validateInvidiousAccount() {
guard let username = account?.username,
let password = account?.password
else {
setValidationResult(false)
return
}
AF
.request(login.url, method: .post, parameters: ["email": username, "password": password], encoding: URLEncoding.default)
.redirect(using: .doNotFollow)
.response { response in
guard let headers = response.response?.headers,
let cookies = headers["Set-Cookie"]
else {
self.setValidationResult(false)
accountRequest
.onSuccess { response in
guard self.account!.username == self.formObjectID.wrappedValue else {
return
}
let sidRegex = #"SID=(?<sid>[^;]*);"#
guard let sidRegex = try? NSRegularExpression(pattern: sidRegex),
let match = sidRegex.matches(in: cookies, range: NSRange(cookies.startIndex..., in: cookies)).first
else {
self.setValidationResult(false)
switch self.app.wrappedValue {
case .invidious:
self.isValid.wrappedValue = true
case .piped:
let error = response.json.dictionaryValue["error"]?.string
let token = response.json.dictionaryValue["token"]?.string
self.isValid.wrappedValue = error?.isEmpty ?? !(token?.isEmpty ?? true)
self.error!.wrappedValue = error
}
}
.onFailure { _ in
guard self.account!.username == self.formObjectID.wrappedValue else {
return
}
let matchRange = match.range(withName: "sid")
if let substringRange = Range(matchRange, in: cookies) {
let sid = String(cookies[substringRange])
if !sid.isEmpty {
self.setValidationResult(true)
}
} else {
self.setValidationResult(false)
}
self.isValid.wrappedValue = false
}
.onCompletion { _ in
self.isValidated.wrappedValue = true
self.isValidating.wrappedValue = false
}
}
func validatePipedAccount() {
guard let request = accountRequest else {
setValidationResult(false)
return
}
request.onSuccess { response in
guard self.account!.username == self.formObjectID.wrappedValue else {
return
}
switch self.app.wrappedValue {
case .invidious:
self.isValid.wrappedValue = true
case .piped:
let error = response.json.dictionaryValue["error"]?.string
let token = response.json.dictionaryValue["token"]?.string
self.isValid.wrappedValue = error?.isEmpty ?? !(token?.isEmpty ?? true)
self.error!.wrappedValue = error
default:
return
}
}
.onFailure { _ in
guard self.account!.username == self.formObjectID.wrappedValue else {
return
}
self.isValid.wrappedValue = false
}
.onCompletion { _ in
self.isValidated.wrappedValue = true
self.isValidating.wrappedValue = false
}
}
func setValidationResult(_ result: Bool) {
isValid.wrappedValue = result
isValidated.wrappedValue = true
isValidating.wrappedValue = false
}
var accountRequest: Siesta.Request? {
var accountRequest: Request {
switch app.wrappedValue {
case .invidious:
guard let password = account.password else { return nil }
return login.request(.post, urlEncoded: ["email": account.username, "password": password])
return feed.load()
case .piped:
return login.request(.post, json: ["username": account.username, "password": account.password])
default:
return nil
}
}
func reset() {
appsToValidateInstance = VideosApp.allCases
app.wrappedValue = nil
isValid.wrappedValue = false
isValidated.wrappedValue = false
isValidating.wrappedValue = false
error?.wrappedValue = nil
}
var invidiousCookieHeader: String {
"SID=\(account.username)"
}
var login: Resource {
resource("/login")
}
var feed: Resource {
resource("/api/v1/auth/feed")
}
var videoResourceBasePath: String {
app.wrappedValue == .invidious ? "/api/v1/videos" : "/streams"
}

View File

@@ -6,32 +6,15 @@ struct AccountsBridge: Defaults.Bridge {
typealias Serializable = [String: String]
func serialize(_ value: Value?) -> Serializable? {
guard let value else {
guard let value = value else {
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,
"instanceID": value.instanceID,
"name": value.name ?? "",
"apiURL": value.url,
"username": value.username,
"password": value.password ?? ""
]
@@ -39,7 +22,7 @@ struct AccountsBridge: Defaults.Bridge {
func deserialize(_ object: Serializable?) -> Value? {
guard
let object,
let object = object,
let id = object["id"],
let instanceID = object["instanceID"],
let url = object["apiURL"],
@@ -51,6 +34,6 @@ struct AccountsBridge: Defaults.Bridge {
let name = object["name"] ?? ""
let password = object["password"]
return Account(id: id, instanceID: instanceID, name: name, urlString: url, username: username, password: password)
return Account(id: id, instanceID: instanceID, name: name, url: url, username: username, password: password)
}
}

View File

@@ -3,15 +3,10 @@ import Defaults
import Foundation
final class AccountsModel: ObservableObject {
static let shared = AccountsModel()
@Published private(set) var current: Account!
@Published private var invidious = InvidiousAPI()
@Published private var piped = PipedAPI()
@Published private var peerTube = PeerTubeAPI()
@Published var publicAccount: Account?
private var cancellables = [AnyCancellable]()
@@ -24,7 +19,7 @@ final class AccountsModel: ObservableObject {
return nil
}
return Self.find(id)
return AccountsModel.find(id)
}
var any: Account? {
@@ -32,18 +27,11 @@ final class AccountsModel: ObservableObject {
}
var app: VideosApp {
current?.instance?.app ?? .local
current?.instance?.app ?? .invidious
}
var api: VideosAPI! {
switch app {
case .piped:
return piped
case .invidious:
return invidious
default:
return peerTube
}
var api: VideosAPI {
app == .piped ? piped : invidious
}
var isEmpty: Bool {
@@ -64,19 +52,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 ??
InstancesModel.shared.all.first?.anonymousAccount
{
setCurrent(account)
}
}
func setCurrent(_ account: Account! = nil) {
guard account != current else {
return
@@ -85,63 +60,40 @@ final class AccountsModel: ObservableObject {
current = account
guard !account.isNil else {
current = nil
return
}
switch account.instance.app {
case .local:
return
case .invidious:
invidious.setAccount(account)
case .piped:
piped.setAccount(account)
case .peerTube:
peerTube.setAccount(account)
}
Defaults[.lastAccountIsPublic] = account.isPublic
if !account.isPublic {
Defaults[.lastAccountID] = account.anonymous ? nil : account.id
Defaults[.lastInstanceID] = account.instanceID
}
Defaults[.lastAccountID] = account.anonymous ? nil : account.id
Defaults[.lastInstanceID] = account.instanceID
}
static func find(_ id: Account.ID) -> Account? {
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? = nil) -> Account {
let account = Account(
instanceID: instance.id,
name: name,
url: instance.apiURL,
username: username,
password: password
)
Defaults[.accounts].append(account)
setCredentials(account, username: username, password: password)
return account
}
static func remove(_ account: Account) {
if let accountIndex = Defaults[.accounts].firstIndex(where: { $0.id == account.id }) {
let account = Defaults[.accounts][accountIndex]
KeychainModel.shared.removeAccountKeys(account)
Defaults[.accounts].remove(at: accountIndex)
}
}
static func setToken(_ account: Account, _ token: String) {
KeychainModel.shared.updateAccountKey(account, "token", token)
}
static func setCredentials(_ account: Account, username: String, password: String) {
KeychainModel.shared.updateAccountKey(account, "username", username)
KeychainModel.shared.updateAccountKey(account, "password", password)
}
static func getCredentials(_ account: Account) -> (String?, String?) {
(
KeychainModel.shared.getAccountKey(account, "username"),
KeychainModel.shared.getAccountKey(account, "password")
)
}
}

View File

@@ -7,35 +7,23 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
let app: VideosApp
let id: String
let name: String
let apiURLString: String
let apiURL: 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, apiURL: String, frontendURL: String? = nil) {
self.app = app
self.id = id ?? UUID().uuidString
self.name = name ?? app.rawValue
self.apiURLString = apiURLString
self.name = name
self.apiURL = apiURL
self.frontendURL = frontendURL
self.proxiesVideos = proxiesVideos
self.invidiousCompanion = invidiousCompanion
}
var apiURL: URL! {
URL(string: apiURLString)
}
var anonymous: VideosAPI! {
var anonymous: VideosAPI {
switch app {
case .invidious:
return InvidiousAPI(account: anonymousAccount)
case .piped:
return PipedAPI(account: anonymousAccount)
case .peerTube:
return PeerTubeAPI(account: anonymousAccount)
case .local:
return nil
}
}
@@ -44,23 +32,23 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
}
var longDescription: String {
name.isEmpty ? "\(app.name) - \(apiURLString)" : "\(app.name) - \(name) (\(apiURLString))"
name.isEmpty ? "\(app.name) - \(apiURL)" : "\(app.name) - \(name) (\(apiURL))"
}
var shortDescription: String {
name.isEmpty ? apiURLString : name
name.isEmpty ? apiURL : name
}
var anonymousAccount: Account {
Account(instanceID: id, name: "Anonymous".localized(), urlString: apiURLString, anonymous: true)
Account(instanceID: id, name: "Anonymous", url: apiURL, anonymous: true)
}
var urlComponents: URLComponents {
URLComponents(url: apiURL, resolvingAgainstBaseURL: false)!
URLComponents(string: apiURL)!
}
var frontendHost: String? {
guard let url = app == .invidious ? apiURLString : frontendURL else {
guard let url = app == .invidious ? apiURL : frontendURL else {
return nil
}
@@ -70,8 +58,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 }
}
}

View File

@@ -6,7 +6,7 @@ struct InstancesBridge: Defaults.Bridge {
typealias Serializable = [String: String]
func serialize(_ value: Value?) -> Serializable? {
guard let value else {
guard let value = value else {
return nil
}
@@ -14,16 +14,14 @@ struct InstancesBridge: Defaults.Bridge {
"app": value.app.rawValue,
"id": value.id,
"name": value.name,
"apiURL": value.apiURLString,
"frontendURL": value.frontendURL ?? "",
"proxiesVideos": value.proxiesVideos ? "true" : "false",
"invidiousCompanion": value.invidiousCompanion ? "true" : "false"
"apiURL": value.apiURL,
"frontendURL": value.frontendURL ?? ""
]
}
func deserialize(_ object: Serializable?) -> Value? {
guard
let object,
let object = object,
let app = VideosApp(rawValue: object["app"] ?? ""),
let id = object["id"],
let apiURL = object["apiURL"]
@@ -32,10 +30,8 @@ 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)
let frontendURL: String? = object["frontendURL"]!.isEmpty ? nil : object["frontendURL"]
return Instance(app: app, id: id, name: name, apiURL: apiURL, frontendURL: frontendURL)
}
}

View File

@@ -2,18 +2,16 @@ import Defaults
import Foundation
final class InstancesModel: ObservableObject {
static var shared = InstancesModel()
var all: [Instance] {
static var all: [Instance] {
Defaults[.instances]
}
var forPlayer: Instance? {
static var forPlayer: Instance? {
guard let id = Defaults[.playerInstanceID] else {
return nil
}
return Self.shared.find(id)
return InstancesModel.find(id)
}
var lastUsed: Instance? {
@@ -21,10 +19,10 @@ final class InstancesModel: ObservableObject {
return nil
}
return Self.shared.find(id)
return InstancesModel.find(id)
}
func find(_ id: Instance.ID?) -> Instance? {
static func find(_ id: Instance.ID?) -> Instance? {
guard id != nil else {
return nil
}
@@ -32,34 +30,20 @@ final class InstancesModel: ObservableObject {
return Defaults[.instances].first { $0.id == id }
}
func findByURLString(_ urlString: String?) -> Instance? {
guard let urlString else { return nil }
return Defaults[.instances].first { $0.apiURLString == urlString }
}
func accounts(_ id: Instance.ID?) -> [Account] {
static func accounts(_ id: Instance.ID?) -> [Account] {
Defaults[.accounts].filter { $0.instanceID == id }
}
func add(id: String? = UUID().uuidString, app: VideosApp, name: String, url: String) -> Instance {
static 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, apiURL: 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) {
static func setFrontendURL(_ instance: Instance, _ url: String) {
if let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) {
var instance = Defaults[.instances][index]
instance.frontendURL = standardizedURL(url)
@@ -68,40 +52,19 @@ final class InstancesModel: ObservableObject {
}
}
func setProxiesVideos(_ instance: Instance, _ proxiesVideos: Bool) {
guard let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) else {
return
}
var instance = Defaults[.instances][index]
instance.proxiesVideos = proxiesVideos
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)
static func remove(_ instance: Instance) {
let accounts = Self.accounts(instance.id)
if let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) {
Defaults[.instances].remove(at: index)
accounts.forEach { AccountsModel.remove($0) }
}
}
func standardizedURL(_ url: String) -> String {
if url.count > 7, url.last == "/" {
static func standardizedURL(_ url: String) -> String {
if url.last == "/" {
return String(url.dropLast())
} else {
return url
}
return url
}
}

View File

@@ -1,4 +1,3 @@
import Alamofire
import AVKit
import Defaults
import Foundation
@@ -10,15 +9,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
@Published var account: Account!
static func withAnonymousAccountForInstanceURL(_ url: URL) -> InvidiousAPI {
.init(account: Instance(app: .invidious, apiURLString: url.absoluteString).anonymousAccount)
}
var signedIn: Bool {
guard let account else { return false }
return !account.anonymous && !(account.token?.isEmpty ?? true)
}
@Published var validInstance = true
@Published var signedIn = false
init(account: Account? = nil) {
super.init()
@@ -33,16 +25,56 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
func setAccount(_ account: Account) {
self.account = account
signedIn = false
validInstance = account.anonymous
configure()
if !account.anonymous {
validate()
}
}
func validate() {
validateInstance()
validateSID()
}
func validateInstance() {
guard !validInstance else {
return
}
home?
.load()
.onSuccess { _ in
self.validInstance = true
}
.onFailure { _ in
self.validInstance = false
}
}
func validateSID() {
guard !signedIn else {
return
}
feed?
.load()
.onSuccess { _ in
self.signedIn = true
}
.onFailure { _ in
self.signedIn = false
}
}
func configure() {
invalidateConfiguration()
configure {
if let cookie = self.cookieHeader {
$0.headers["Cookie"] = cookie
if !self.account.username.isEmpty {
$0.headers["Cookie"] = self.cookieHeader
}
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
}
@@ -61,15 +93,13 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
configureTransformer(pathPattern("search"), requestMethods: [.get]) { (content: Entity<JSON>) -> SearchPage in
let results = content.json.arrayValue.compactMap { json -> ContentItem? in
let type = json.dictionaryValue["type"]?.string
let type = json.dictionaryValue["type"]?.stringValue
if type == "channel" {
return ContentItem(channel: self.extractChannel(from: json))
}
if type == "playlist" {
} else if type == "playlist" {
return ContentItem(playlist: self.extractChannelPlaylist(from: json))
}
if type == "video" {
} else if type == "video" {
return ContentItem(video: self.extractVideo(from: json))
}
@@ -81,7 +111,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(String.init)
}
return []
@@ -96,7 +126,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
}
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.post, .patch]) { (content: Entity<Data>) -> Playlist in
self.extractPlaylist(from: JSON(parseJSON: String(data: content.content, encoding: .utf8)!))
// hacky, to verify if possible to get it in easier way
Playlist(JSON(parseJSON: String(data: content.content, encoding: .utf8)!))
}
configureTransformer(pathPattern("auth/feed"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
@@ -111,22 +142,12 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
content.json.arrayValue.map(self.extractChannel)
}
configureTransformer(pathPattern("channels/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPage in
self.extractChannelPage(from: content.json, forceNotLast: true)
}
configureTransformer(pathPattern("channels/*/videos"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPage in
self.extractChannelPage(from: content.json)
configureTransformer(pathPattern("channels/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Channel in
self.extractChannel(from: content.json)
}
configureTransformer(pathPattern("channels/*/latest"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
content.json.dictionaryValue["videos"]?.arrayValue.map(self.extractVideo) ?? []
}
for type in ["latest", "playlists", "streams", "shorts", "channels", "videos", "releases", "podcasts"] {
configureTransformer(pathPattern("channels/*/\(type)"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPage in
self.extractChannelPage(from: content.json)
}
content.json.arrayValue.map(self.extractVideo)
}
configureTransformer(pathPattern("playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPlaylist in
@@ -136,99 +157,6 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
configureTransformer(pathPattern("videos/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Video in
self.extractVideo(from: content.json)
}
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["continuation"]?.string
let disabled = !details["error"].isNil
return CommentsPage(comments: comments, nextPage: nextPage, disabled: disabled)
}
if account.token.isNil || account.token!.isEmpty {
updateToken()
} else {
FeedModel.shared.onAccountChange()
SubscribedChannelsModel.shared.onAccountChange()
PlaylistsModel.shared.onAccountChange()
DispatchQueue.main.async {
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
}
}
}
func updateToken(force: Bool = false) {
let (username, password) = AccountsModel.getCredentials(account)
guard !account.anonymous,
(account.token?.isEmpty ?? true) || force
else {
DispatchQueue.main.async {
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
}
return
}
guard let username,
let password,
!username.isEmpty,
!password.isEmpty
else {
NavigationModel.shared.presentAlert(
title: "Account Error",
message: "Remove and add your account again in Settings."
)
DispatchQueue.main.async {
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
}
return
}
let presentTokenUpdateFailedAlert: (AFDataResponse<Data?>?, String?) -> Void = { response, message in
NavigationModel.shared.presentAlert(
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."
)
}
AF
.request(login.url, method: .post, parameters: ["email": username, "password": password], encoding: URLEncoding.default)
.redirect(using: .doNotFollow)
.response { response in
guard let headers = response.response?.headers,
let cookies = headers["Set-Cookie"]
else {
presentTokenUpdateFailedAlert(response, nil)
return
}
let sidRegex = #"SID=(?<sid>[^;]*);"#
guard let sidRegex = try? NSRegularExpression(pattern: sidRegex),
let match = sidRegex.matches(in: cookies, range: NSRange(cookies.startIndex..., in: cookies)).first
else {
presentTokenUpdateFailedAlert(nil, String(format: "Could not extract SID from received cookies: %@".localized(), cookies))
return
}
let matchRange = match.range(withName: "sid")
if let substringRange = Range(matchRange, in: cookies) {
let sid = String(cookies[substringRange])
AccountsModel.setToken(self.account, sid)
self.objectWillChange.send()
} else {
presentTokenUpdateFailedAlert(nil, String(format: "Could not extract SID from received cookies: %@".localized(), cookies))
}
self.configure()
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
}
}
var login: Resource {
resource(baseURL: account.url, path: "login")
}
private func pathPattern(_ path: String) -> String {
@@ -239,9 +167,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
"\(Self.basePath)/\(path)"
}
private var cookieHeader: String? {
guard let token = account?.token, !token.isEmpty else { return nil }
return "SID=\(token)"
private var cookieHeader: String {
"SID=\(account.username)"
}
var popular: Resource? {
@@ -250,7 +177,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
func trending(country: Country, category: TrendingCategory?) -> Resource {
resource(baseURL: account.url, path: "\(Self.basePath)/trending")
.withParam("type", category?.type)
.withParam("type", category?.name)
.withParam("region", country.rawValue)
}
@@ -258,53 +185,30 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
resource(baseURL: account.url, path: "/feed/subscriptions")
}
func feed(_ page: Int?) -> Resource? {
resourceWithAuthCheck(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: "\(Self.basePath)/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() }
}
func channel(_ id: String, contentType: Channel.ContentType, data _: String?, page: String?) -> Resource {
if page.isNil, contentType == .videos {
return resource(baseURL: account.url, path: basePathAppending("channels/\(id)"))
}
var resource = resource(baseURL: account.url, path: basePathAppending("channels/\(id)/\(contentType.invidiousID)"))
if let page, !page.isEmpty {
resource = resource.withParam("continuation", page)
}
return resource
}
func channelByName(_: String) -> Resource? {
nil
}
func channelByUsername(_: String) -> Resource? {
nil
func channel(_ id: String) -> Resource {
resource(baseURL: account.url, path: basePathAppending("channels/\(id)"))
}
func channelVideos(_ id: String) -> Resource {
@@ -320,11 +224,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? {
@@ -413,7 +317,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
resource = resource.withParam("duration", duration.rawValue)
}
if let page {
if let page = page {
resource = resource.withParam("page", page)
}
@@ -425,12 +329,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
.withParam("q", query.lowercased())
}
func comments(_ id: Video.ID, page: String?) -> Resource? {
let resource = resource(baseURL: account.url, path: basePathAppending("comments/\(id)"))
guard let page else { return resource }
return resource.withParam("continuation", page)
}
func comments(_: Video.ID, page _: String?) -> Resource? { nil }
private func searchQuery(_ query: String) -> String {
var searchQuery = query
@@ -452,14 +351,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
}
static func proxiedAsset(instance: Instance, asset: AVURLAsset) -> AVURLAsset? {
guard let instanceURLComponents = URLComponents(url: instance.apiURL, resolvingAgainstBaseURL: false),
guard let instanceURLComponents = URLComponents(string: instance.apiURL),
var urlComponents = URLComponents(url: asset.url, resolvingAgainstBaseURL: false) else { return nil }
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
@@ -471,12 +367,10 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
func extractVideo(from json: JSON) -> Video {
let indexID: String?
var id: Video.ID
var published = json["publishedText"].stringValue
var publishedAt: Date?
if let publishedInterval = json["published"].double {
publishedAt = Date(timeIntervalSince1970: publishedInterval)
published = ""
}
let videoID = json["videoId"].stringValue
@@ -489,313 +383,103 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
id = videoID
}
let description = json["description"].stringValue
let length = json["lengthSeconds"].doubleValue
return Video(
instanceID: account.instanceID,
app: .invidious,
instanceURL: account.instance.apiURL,
id: id,
videoID: videoID,
title: json["title"].stringValue,
author: json["author"].stringValue,
length: length,
published: published,
length: json["lengthSeconds"].doubleValue,
published: json["publishedText"].stringValue,
views: json["viewCount"].intValue,
description: description,
description: json["description"].stringValue,
genre: json["genre"].stringValue,
channel: extractChannel(from: json),
thumbnails: extractThumbnails(from: json),
indexID: indexID,
live: json["liveNow"].boolValue,
upcoming: json["isUpcoming"].boolValue,
short: length <= Video.shortLength && length != 0.0,
publishedAt: publishedAt,
likes: json["likeCount"].int,
dislikes: json["dislikeCount"].int,
keywords: json["keywords"].arrayValue.compactMap(\.string),
keywords: json["keywords"].arrayValue.map { $0.stringValue },
streams: extractStreams(from: json),
related: extractRelated(from: json),
chapters: createChapters(from: description, thumbnails: json),
captions: extractCaptions(from: json)
related: extractRelated(from: json)
)
}
func extractChannel(from json: JSON) -> Channel {
var thumbnailURL = json["authorThumbnails"].arrayValue.last?.dictionaryValue["url"]?.string ?? ""
var thumbnailURL = json["authorThumbnails"].arrayValue.last?.dictionaryValue["url"]?.stringValue ?? ""
// append protocol to unproxied thumbnail URL if it's missing
// append https protocol to unproxied thumbnail URL if it's missing
if thumbnailURL.count > 2,
String(thumbnailURL[..<thumbnailURL.index(thumbnailURL.startIndex, offsetBy: 2)]) == "//",
let accountUrlComponents = URLComponents(string: account.urlString)
String(thumbnailURL[..<thumbnailURL.index(thumbnailURL.startIndex, offsetBy: 2)]) == "//"
{
thumbnailURL = "\(accountUrlComponents.scheme ?? "https"):\(thumbnailURL)"
}
let tabs = json["tabs"].arrayValue.compactMap { name in
if let name = name.string, let type = Channel.ContentType.from(name) {
return Channel.Tab(contentType: type, data: "")
}
return nil
thumbnailURL = "https:\(thumbnailURL)"
}
return Channel(
app: .invidious,
id: json["authorId"].stringValue,
name: json["author"].stringValue,
bannerURL: json["authorBanners"].arrayValue.first?.dictionaryValue["url"]?.url,
thumbnailURL: URL(string: thumbnailURL),
description: json["description"].stringValue,
subscriptionsCount: json["subCount"].int,
subscriptionsText: json["subCountText"].string,
totalViews: json["totalViews"].int,
videos: json.dictionaryValue["latestVideos"]?.arrayValue.map(extractVideo) ?? [],
tabs: tabs
videos: json.dictionaryValue["latestVideos"]?.arrayValue.map(extractVideo) ?? []
)
}
func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist {
let details = json.dictionaryValue
return ChannelPlaylist(
id: details["playlistId"]?.string ?? details["mixId"]?.string ?? UUID().uuidString,
title: details["title"]?.stringValue ?? "",
id: details["playlistId"]!.stringValue,
title: details["title"]!.stringValue,
thumbnailURL: details["playlistThumbnail"]?.url,
channel: extractChannel(from: json),
videos: details["videos"]?.arrayValue.compactMap(extractVideo) ?? [],
videosCount: details["videoCount"]?.int
videos: details["videos"]?.arrayValue.compactMap(extractVideo) ?? []
)
}
// 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,
var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let quality = json["quality"].string,
let accountUrlComponents = URLComponents(string: account.urlString)
else {
return nil
}
// Some instances are not configured properly and return thumbnail links
// with an incorrect scheme or a missing port.
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
}
return Thumbnail(url: thumbnailUrl, quality: .init(rawValue: quality)!)
details["videoThumbnails"].arrayValue.map { json in
Thumbnail(url: json["url"].url!, quality: .init(rawValue: json["quality"].string!)!)
}
}
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 {
let nextPage = json.dictionaryValue["continuation"]?.string
var contentItems = [ContentItem]()
if let key = Self.contentItemsKeys.first(where: { json.dictionaryValue.keys.contains($0) }),
let items = json.dictionaryValue[key]
{
contentItems = extractContentItems(from: items)
}
var last = false
if !forceNotLast {
last = nextPage?.isEmpty ?? true
}
return ChannelPage(
results: contentItems,
channel: extractChannel(from: json),
nextPage: nextPage,
last: last
)
}
private func extractStreams(from json: JSON) -> [Stream] {
let hls = extractHLSStreams(from: json)
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) +
hls
extractFormatStreams(from: json["formatStreams"].arrayValue) +
extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue)
}
private func extractFormatStreams(from streams: [JSON], videoId: String?) -> [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),
resolution: Stream.Resolution.from(resolution: stream["resolution"].string ?? ""),
private func extractFormatStreams(from streams: [JSON]) -> [Stream] {
streams.map {
SingleAssetStream(
avAsset: AVURLAsset(url: $0["url"].url!),
resolution: Stream.Resolution.from(resolution: $0["resolution"].stringValue),
kind: .stream,
encoding: stream["encoding"].string ?? ""
encoding: $0["encoding"].stringValue
)
}
}
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 [:]
private func extractAdaptiveFormats(from streams: [JSON]) -> [Stream] {
let audioAssetURL = streams.first { $0["type"].stringValue.starts(with: "audio/mp4") }
guard audioAssetURL != nil else {
return []
}
guard let decoded = xtagsValue.removingPercentEncoding else { return [:] }
let videoAssetsURLs = streams.filter { $0["type"].stringValue.starts(with: "video/") }
// 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
.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 {
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
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),
resolution: Stream.Resolution.from(resolution: videoStream["resolution"].stringValue),
return videoAssetsURLs.map {
Stream(
audioAsset: AVURLAsset(url: audioAssetURL!["url"].url!),
videoAsset: AVURLAsset(url: $0["url"].url!),
resolution: Stream.Resolution.from(resolution: $0["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
encoding: $0["encoding"].stringValue,
videoFormat: $0["type"].stringValue
)
}
}
private func extractHLSStreams(from content: JSON) -> [Stream] {
if let hlsURL = content.dictionaryValue["hlsUrl"]?.url {
return [Stream(instance: account.instance, hlsURL: hlsURL)]
}
return []
}
private func extractRelated(from content: JSON) -> [Video] {
content
.dictionaryValue["recommendedVideos"]?
@@ -804,91 +488,12 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
}
private func extractPlaylist(from content: JSON) -> Playlist {
let id = content["playlistId"].stringValue
return Playlist(
id: id,
.init(
id: content["playlistId"].stringValue,
title: content["title"].stringValue,
visibility: content["isListed"].boolValue ? .public : .private,
editable: id.starts(with: "IV"),
updated: content["updated"].doubleValue,
videos: content["videos"].arrayValue.map { extractVideo(from: $0) }
)
}
private func extractComment(from content: JSON) -> Comment? {
let details = content.dictionaryValue
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,
authorAvatarURL: authorAvatarURL,
time: details["publishedText"]?.string ?? "",
pinned: false,
hearted: false,
likeCount: details["likeCount"]?.int ?? 0,
text: decodedContent,
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 }
return Captions(
label: details["label"].stringValue,
code: details["language_code"].stringValue,
url: url
)
}
}
private func extractContentItems(from json: JSON) -> [ContentItem] {
json.arrayValue.compactMap { extractContentItem(from: $0) }
}
private func extractContentItem(from json: JSON) -> ContentItem? {
let type = json.dictionaryValue["type"]?.string
if type == "channel" {
return ContentItem(channel: extractChannel(from: json))
}
if type == "playlist" {
return ContentItem(playlist: extractChannelPlaylist(from: json))
}
if type == "video" {
return ContentItem(video: extractVideo(from: json))
}
return nil
}
}
extension Channel.ContentType {
var invidiousID: String {
switch self {
case .livestreams:
return "streams"
default:
return rawValue
}
}
}

View File

@@ -1,603 +0,0 @@
import Alamofire
import AVKit
import Defaults
import Foundation
import Siesta
import SwiftyJSON
final class PeerTubeAPI: Service, ObservableObject, VideosAPI {
static let basePath = "/api/v1"
@Published var account: Account!
@Published var validInstance = true
var signedIn: Bool {
guard let account else { return false }
return !account.anonymous && !(account.token?.isEmpty ?? true)
}
static func withAnonymousAccountForInstanceURL(_ url: URL) -> PeerTubeAPI {
.init(account: Instance(app: .peerTube, apiURLString: url.absoluteString).anonymousAccount)
}
init(account: Account? = nil) {
super.init()
guard !account.isNil else {
self.account = .init(name: "Empty")
return
}
setAccount(account!)
}
func setAccount(_ account: Account) {
self.account = account
validInstance = account.anonymous
configure()
if !account.anonymous {
validate()
}
}
func validate() {
validateInstance()
validateSID()
}
func validateInstance() {
guard !validInstance else {
return
}
home?
.load()
.onSuccess { _ in
self.validInstance = true
}
.onFailure { _ in
self.validInstance = false
}
}
func validateSID() {
guard signedIn, !(account.token?.isEmpty ?? true) else {
return
}
feed(1)?
.load()
.onFailure { _ in
self.updateToken(force: true)
}
}
func configure() {
invalidateConfiguration()
configure {
if let cookie = self.cookieHeader {
$0.headers["Cookie"] = cookie
}
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
}
configure("**", requestMethods: [.post]) {
$0.pipeline[.parsing].removeTransformers()
}
configureTransformer(pathPattern("popular"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
content.json.arrayValue.map(self.extractVideo)
}
configureTransformer(pathPattern("videos"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
content.json.dictionaryValue["data"]?.arrayValue.map(self.extractVideo) ?? []
}
configureTransformer(pathPattern("search/videos"), requestMethods: [.get]) { (content: Entity<JSON>) -> SearchPage in
let results = content.json.dictionaryValue["data"]?.arrayValue.compactMap { json -> ContentItem in .init(video: self.extractVideo(from: json)) } ?? []
return SearchPage(results: results, last: results.isEmpty)
}
configureTransformer(pathPattern("search/suggestions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [String] in
if let suggestions = content.json.dictionaryValue["suggestions"] {
return suggestions.arrayValue.map(String.init)
}
return []
}
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Playlist] in
content.json.arrayValue.map(self.extractPlaylist)
}
configureTransformer(pathPattern("auth/playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Playlist in
self.extractPlaylist(from: content.json)
}
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.post, .patch]) { (content: Entity<Data>) -> Playlist in
self.extractPlaylist(from: JSON(parseJSON: String(data: content.content, encoding: .utf8)!))
}
configureTransformer(pathPattern("auth/feed"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
if let feedVideos = content.json.dictionaryValue["videos"] {
return feedVideos.arrayValue.map(self.extractVideo)
}
return []
}
configureTransformer(pathPattern("auth/subscriptions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Channel] in
content.json.arrayValue.map(self.extractChannel)
}
configureTransformer(pathPattern("channels/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Channel in
self.extractChannel(from: content.json)
}
configureTransformer(pathPattern("channels/*/latest"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
content.json.arrayValue.map(self.extractVideo)
}
configureTransformer(pathPattern("channels/*/playlists"), requestMethods: [.get]) { (content: Entity<JSON>) -> [ContentItem] in
let playlists = (content.json.dictionaryValue["playlists"]?.arrayValue ?? []).compactMap { self.extractChannelPlaylist(from: $0) }
return ContentItem.array(of: playlists)
}
configureTransformer(pathPattern("playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPlaylist in
self.extractChannelPlaylist(from: content.json)
}
configureTransformer(pathPattern("videos/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Video in
self.extractVideo(from: content.json)
}
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["continuation"]?.string
let disabled = !details["error"].isNil
return CommentsPage(comments: comments, nextPage: nextPage, disabled: disabled)
}
updateToken()
}
func updateToken(force: Bool = false) {
let (username, password) = AccountsModel.getCredentials(account)
guard !account.anonymous,
(account.token?.isEmpty ?? true) || force
else {
DispatchQueue.main.async {
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
}
return
}
guard let username,
let password,
!username.isEmpty,
!password.isEmpty
else {
NavigationModel.shared.presentAlert(
title: "Account Error",
message: "Remove and add your account again in Settings."
)
DispatchQueue.main.async {
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
}
return
}
let presentTokenUpdateFailedAlert: (AFDataResponse<Data?>?, String?) -> Void = { response, message in
NavigationModel.shared.presentAlert(
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
.request(login.url, method: .post, parameters: ["email": username, "password": password], encoding: URLEncoding.default)
.redirect(using: .doNotFollow)
.response { response in
guard let headers = response.response?.headers,
let cookies = headers["Set-Cookie"]
else {
presentTokenUpdateFailedAlert(response, nil)
return
}
let sidRegex = #"SID=(?<sid>[^;]*);"#
guard let sidRegex = try? NSRegularExpression(pattern: sidRegex),
let match = sidRegex.matches(in: cookies, range: NSRange(cookies.startIndex..., in: cookies)).first
else {
presentTokenUpdateFailedAlert(nil, String(format: "Could not extract SID from received cookies: %@".localized(), cookies))
return
}
let matchRange = match.range(withName: "sid")
if let substringRange = Range(matchRange, in: cookies) {
let sid = String(cookies[substringRange])
AccountsModel.setToken(self.account, sid)
self.objectWillChange.send()
} else {
presentTokenUpdateFailedAlert(nil, String(format: "Could not extract SID from received cookies: %@".localized(), cookies))
}
self.configure()
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
}
}
var login: Resource {
resource(baseURL: account.url, path: "login")
}
private func pathPattern(_ path: String) -> String {
"**\(Self.basePath)/\(path)"
}
private func basePathAppending(_ path: String) -> String {
"\(Self.basePath)/\(path)"
}
private var cookieHeader: String? {
guard let token = account?.token, !token.isEmpty else { return nil }
return "SID=\(token)"
}
var popular: Resource? {
resource(baseURL: account.url, path: "\(Self.basePath)/popular")
}
func trending(country _: Country, category _: TrendingCategory?) -> Resource {
resource(baseURL: account.url, path: "\(Self.basePath)/videos")
.withParam("isLocal", "true")
// .withParam("type", category?.name)
// .withParam("region", country.rawValue)
}
var home: Resource? {
resource(baseURL: account.url, path: "/feed/subscriptions")
}
func feed(_ page: Int?) -> Resource? {
resource(baseURL: account.url, path: "\(Self.basePath)/auth/feed")
.withParam("page", String(page ?? 1))
}
var subscriptions: Resource? {
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
}
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
.child(channelID)
.request(.post)
.onCompletion { _ in onCompletion() }
}
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void) {
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
.child(channelID)
.request(.delete)
.onCompletion { _ in onCompletion() }
}
func channel(_ id: String, contentType: Channel.ContentType, data _: String?, page _: String?) -> Resource {
if contentType == .playlists {
return resource(baseURL: account.url, path: basePathAppending("channels/\(id)/playlists"))
}
return resource(baseURL: account.url, path: basePathAppending("channels/\(id)"))
}
func channelByName(_: String) -> Resource? {
nil
}
func channelByUsername(_: String) -> Resource? {
nil
}
func channelVideos(_ id: String) -> Resource {
resource(baseURL: account.url, path: basePathAppending("channels/\(id)/latest"))
}
func video(_ id: String) -> Resource {
resource(baseURL: account.url, path: basePathAppending("videos/\(id)"))
}
var playlists: Resource? {
if account.isNil || account.anonymous {
return nil
}
return resource(baseURL: account.url, path: basePathAppending("auth/playlists"))
}
func playlist(_ id: String) -> Resource? {
resource(baseURL: account.url, path: basePathAppending("auth/playlists/\(id)"))
}
func playlistVideos(_ id: String) -> Resource? {
playlist(id)?.child("videos")
}
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource? {
playlist(playlistID)?.child("videos").child(videoID)
}
func addVideoToPlaylist(
_ videoID: String,
_ playlistID: String,
onFailure: @escaping (RequestError) -> Void = { _ in },
onSuccess: @escaping () -> Void = {}
) {
let resource = playlistVideos(playlistID)
let body = ["videoId": videoID]
resource?
.request(.post, json: body)
.onSuccess { _ in onSuccess() }
.onFailure(onFailure)
}
func removeVideoFromPlaylist(
_ index: String,
_ playlistID: String,
onFailure: @escaping (RequestError) -> Void,
onSuccess: @escaping () -> Void
) {
let resource = playlistVideo(playlistID, index)
resource?
.request(.delete)
.onSuccess { _ in onSuccess() }
.onFailure(onFailure)
}
func playlistForm(
_ name: String,
_ visibility: String,
playlist: Playlist?,
onFailure: @escaping (RequestError) -> Void,
onSuccess: @escaping (Playlist?) -> Void
) {
let body = ["title": name, "privacy": visibility]
let resource = !playlist.isNil ? self.playlist(playlist!.id) : playlists
resource?
.request(!playlist.isNil ? .patch : .post, json: body)
.onSuccess { response in
if let modifiedPlaylist: Playlist = response.typedContent() {
onSuccess(modifiedPlaylist)
}
}
.onFailure(onFailure)
}
func deletePlaylist(
_ playlist: Playlist,
onFailure: @escaping (RequestError) -> Void,
onSuccess: @escaping () -> Void
) {
self.playlist(playlist.id)?
.request(.delete)
.onSuccess { _ in onSuccess() }
.onFailure(onFailure)
}
func channelPlaylist(_ id: String) -> Resource? {
resource(baseURL: account.url, path: basePathAppending("playlists/\(id)"))
}
func search(_ query: SearchQuery, page _: String?) -> Resource {
resource(baseURL: account.url, path: basePathAppending("search/videos"))
.withParam("search", query.query)
// .withParam("sort_by", query.sortBy.parameter)
// .withParam("type", "all")
//
// if let date = query.date, date != .any {
// resource = resource.withParam("date", date.rawValue)
// }
//
// if let duration = query.duration, duration != .any {
// resource = resource.withParam("duration", duration.rawValue)
// }
//
// if let page {
// resource = resource.withParam("page", page)
// }
// return resource
}
func searchSuggestions(query: String) -> Resource {
resource(baseURL: account.url, path: basePathAppending("search/suggestions"))
.withParam("q", query.lowercased())
}
func comments(_ id: Video.ID, page: String?) -> Resource? {
let resource = resource(baseURL: account.url, path: basePathAppending("comments/\(id)"))
guard let page else { return resource }
return resource.withParam("continuation", page)
}
static func proxiedAsset(instance: Instance, asset: AVURLAsset) -> AVURLAsset? {
guard let instanceURLComponents = URLComponents(string: instance.apiURLString),
var urlComponents = URLComponents(url: asset.url, resolvingAgainstBaseURL: false) else { return nil }
urlComponents.scheme = instanceURLComponents.scheme
urlComponents.host = instanceURLComponents.host
guard let url = urlComponents.url else {
return nil
}
return AVURLAsset(url: url)
}
func extractVideo(from json: JSON) -> Video {
let id = json["uuid"].stringValue
let url = json["url"].url
let dateFormatter = ISO8601DateFormatter()
dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let publishedAt = dateFormatter.date(from: json["publishedAt"].stringValue)
return Video(
instanceID: account.instanceID,
app: .peerTube,
instanceURL: account.instance.apiURL,
id: id,
videoID: id,
videoURL: url,
title: json["name"].stringValue,
author: json["channel"].dictionaryValue["name"]?.stringValue ?? "",
length: json["duration"].doubleValue,
views: json["views"].intValue,
description: json["description"].stringValue,
channel: extractChannel(from: json["channel"]),
thumbnails: extractThumbnails(from: json),
live: json["isLive"].boolValue,
publishedAt: publishedAt,
likes: json["likes"].int,
dislikes: json["dislikes"].int,
streams: extractStreams(from: json)
// related: extractRelated(from: json),
// chapters: extractChapters(from: description),
// captions: extractCaptions(from: json)
)
}
func extractChannel(from json: JSON) -> Channel {
Channel(
app: .peerTube,
id: json["id"].stringValue,
name: json["name"].stringValue
)
}
func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist {
let details = json.dictionaryValue
return ChannelPlaylist(
id: details["playlistId"]?.string ?? details["mixId"]?.string ?? UUID().uuidString,
title: details["title"]?.stringValue ?? "",
thumbnailURL: details["playlistThumbnail"]?.url,
channel: extractChannel(from: json),
videos: details["videos"]?.arrayValue.compactMap(extractVideo) ?? [],
videosCount: details["videoCount"]?.int
)
}
private func extractThumbnails(from details: JSON) -> [Thumbnail] {
if let thumbnailPath = details["thumbnailPath"].string {
return [Thumbnail(url: URL(string: thumbnailPath, relativeTo: account.url)!, quality: .medium)]
}
return []
}
private func extractStreams(from json: JSON) -> [Stream] {
let hls = extractHLSStreams(from: json)
if json["isLive"].boolValue {
return hls
}
return extractFormatStreams(from: json) +
extractAdaptiveFormats(from: json) +
hls
}
private func extractFormatStreams(from json: JSON) -> [Stream] {
var streams = [Stream]()
if let fileURL = json.dictionaryValue["streamingPlaylists"]?.arrayValue.first?
.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))
}
return streams
}
private func extractAdaptiveFormats(from json: JSON) -> [Stream] {
json.dictionaryValue["files"]?.arrayValue.compactMap { file in
if let resolution = file.dictionaryValue["resolution"]?.dictionaryValue["label"]?.stringValue, let url = file.dictionaryValue["fileUrl"]?.url {
return SingleAssetStream(instance: account.instance, avAsset: AVURLAsset(url: url), resolution: Stream.Resolution.from(resolution: resolution), kind: .adaptive, videoFormat: "mp4")
}
return nil
} ?? []
}
private func extractHLSStreams(from content: JSON) -> [Stream] {
if let hlsURL = content.dictionaryValue["streamingPlaylists"]?.arrayValue.first?.dictionaryValue["playlistUrl"]?.url {
return [Stream(instance: account.instance, hlsURL: hlsURL)]
}
return []
}
private func extractRelated(from content: JSON) -> [Video] {
content
.dictionaryValue["recommendedVideos"]?
.arrayValue
.compactMap(extractVideo(from:)) ?? []
}
private func extractPlaylist(from content: JSON) -> Playlist {
let id = content["playlistId"].stringValue
return Playlist(
id: id,
title: content["title"].stringValue,
visibility: content["isListed"].boolValue ? .public : .private,
editable: id.starts(with: "IV"),
updated: content["updated"].doubleValue,
videos: content["videos"].arrayValue.map { extractVideo(from: $0) }
)
}
private func extractComment(from content: JSON) -> Comment? {
let details = content.dictionaryValue
let author = details["author"]?.string ?? ""
let channelId = details["authorId"]?.string ?? UUID().uuidString
let authorAvatarURL = details["authorThumbnails"]?.arrayValue.last?.dictionaryValue["url"]?.string ?? ""
return Comment(
id: UUID().uuidString,
author: author,
authorAvatarURL: authorAvatarURL,
time: details["publishedText"]?.string ?? "",
pinned: false,
hearted: false,
likeCount: details["likeCount"]?.int ?? 0,
text: details["content"]?.string ?? "",
repliesPage: details["replies"]?.dictionaryValue["continuation"]?.string,
channel: Channel(app: .peerTube, id: channelId, name: author)
)
}
private func extractCaptions(from content: JSON) -> [Captions] {
content["captions"].arrayValue.compactMap { _ in
nil
// let baseURL = account.url
// guard let url = URL(string: baseURL + details["url"].stringValue) else { return nil }
//
// return Captions(
// label: details["label"].stringValue,
// code: details["language_code"].stringValue,
// url: url
// )
}
}
}

View File

@@ -1,20 +1,13 @@
import Alamofire
import AVFoundation
import Foundation
import Siesta
import SwiftyJSON
final class PipedAPI: Service, ObservableObject, VideosAPI {
static var disallowedVideoCodecs = ["av01"]
static var authorizedEndpoints = ["subscriptions", "subscribe", "unsubscribe", "user/playlists"]
static var contentItemsKeys = ["items", "content", "relatedStreams"]
@Published var account: Account!
static func withAnonymousAccountForInstanceURL(_ url: URL) -> PipedAPI {
.init(account: Instance(app: .piped, apiURLString: url.absoluteString).anonymousAccount)
}
init(account: Account? = nil) {
super.init()
@@ -42,36 +35,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
$0.headers["Authorization"] = self.account.token
}
configureTransformer(pathPattern("channel/*")) { (content: Entity<JSON>) -> ChannelPage in
let nextPage = content.json.dictionaryValue["nextpage"]?.string
let channel = self.extractChannel(from: content.json)
return ChannelPage(
results: self.extractContentItems(from: self.contentItemsDictionary(from: content.json)),
channel: channel,
nextPage: nextPage,
last: nextPage.isNil
)
}
configureTransformer(pathPattern("/nextpage/channel/*")) { (content: Entity<JSON>) -> ChannelPage in
let nextPage = content.json.dictionaryValue["nextpage"]?.string
return ChannelPage(
results: self.extractContentItems(from: self.contentItemsDictionary(from: content.json)),
channel: self.extractChannel(from: content.json),
nextPage: nextPage,
last: nextPage.isNil
)
}
configureTransformer(pathPattern("channels/tabs*")) { (content: Entity<JSON>) -> [ContentItem] in
(content.json.dictionaryValue["content"]?.arrayValue ?? []).compactMap { self.extractContentItem(from: $0) }
}
configureTransformer(pathPattern("c/*")) { (content: Entity<JSON>) -> Channel? in
self.extractChannel(from: content.json)
}
configureTransformer(pathPattern("user/*")) { (content: Entity<JSON>) -> Channel? in
configureTransformer(pathPattern("channel/*")) { (content: Entity<JSON>) -> Channel? in
self.extractChannel(from: content.json)
}
@@ -93,7 +57,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
}
configureTransformer(pathPattern("search")) { (content: Entity<JSON>) -> SearchPage in
let nextPage = content.json.dictionaryValue["nextpage"]?.string
let nextPage = content.json.dictionaryValue["nextpage"]?.stringValue
return SearchPage(
results: self.extractContentItems(from: content.json.dictionaryValue["items"]!),
nextPage: nextPage,
@@ -106,39 +70,28 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
}
configureTransformer(pathPattern("subscriptions")) { (content: Entity<JSON>) -> [Channel] in
content.json.arrayValue.compactMap { self.extractChannel(from: $0) }
content.json.arrayValue.map { self.extractChannel(from: $0)! }
}
configureTransformer(pathPattern("feed")) { (content: Entity<JSON>) -> [Video] in
content.json.arrayValue.compactMap { self.extractVideo(from: $0) }
content.json.arrayValue.map { 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)
}
let comments = details["comments"]?.arrayValue.compactMap { self.extractComment(from: $0) } ?? []
let nextPage = details["nextpage"]?.string
let disabled = details["disabled"]?.bool ?? false
configureTransformer(pathPattern("comments/*")) { (content: Entity<JSON>) -> CommentsPage in
let details = content.json.dictionaryValue
let comments = details["comments"]?.arrayValue.map { self.extractComment(from: $0)! } ?? []
let nextPage = details["nextpage"]?.stringValue
let disabled = details["disabled"]?.boolValue ?? false
return CommentsPage(comments: comments, nextPage: nextPage, disabled: disabled)
}
configureTransformer(pathPattern("user/playlists")) { (content: Entity<JSON>) -> [Playlist] in
content.json.arrayValue.compactMap { self.extractUserPlaylist(from: $0) }
content.json.arrayValue.map { self.extractUserPlaylist(from: $0)! }
}
if account.token.isNil || account.token!.isEmpty {
if account.token.isNil {
updateToken()
} else {
FeedModel.shared.onAccountChange()
SubscribedChannelsModel.shared.onAccountChange()
PlaylistsModel.shared.onAccountChange()
DispatchQueue.main.async {
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
}
}
}
@@ -147,59 +100,19 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
}
func updateToken() {
let (username, password) = AccountsModel.getCredentials(account)
guard !account.anonymous,
let username,
let password
else {
DispatchQueue.main.async {
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
}
guard !account.anonymous else {
return
}
AF.request(
login.url,
method: .post,
parameters: ["username": username, "password": password],
encoding: JSONEncoding.default
account.token = nil
login.request(
.post,
json: ["username": account.username, "password": account.password]
)
.responseDecodable(of: JSON.self) { [weak self] response in
guard let self else {
return
}
switch response.result {
case let .success(value):
let json = JSON(value)
let token = json.dictionaryValue["token"]?.string ?? ""
if let error = json.dictionaryValue["error"]?.string {
NavigationModel.shared.presentAlert(
title: "Account Error",
message: error
)
} else if !token.isEmpty {
AccountsModel.setToken(self.account, token)
self.objectWillChange.send()
} else {
NavigationModel.shared.presentAlert(
title: "Account Error",
message: "Could not update your token."
)
}
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)
}
.onSuccess { response in
self.account.token = response.json.dictionaryValue["token"]?.string ?? ""
self.configure()
}
}
@@ -207,35 +120,12 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
resource(baseURL: account.url, path: "login")
}
func channel(_ id: String, contentType: Channel.ContentType, data: String?, page: String?) -> Resource {
let path = page.isNil ? "channel" : "nextpage/channel"
var channel: Siesta.Resource
if contentType == .videos || data.isNil {
channel = resource(baseURL: account.url, path: "\(path)/\(id)")
} else {
channel = resource(baseURL: account.url, path: "channels/tabs")
.withParam("data", data)
}
if let page, !page.isEmpty {
channel = channel.withParam("nextpage", page)
}
return channel
}
func channelByName(_ name: String) -> Resource? {
resource(baseURL: account.url, path: "c/\(name)")
}
func channelByUsername(_ username: String) -> Resource? {
resource(baseURL: account.url, path: "user/\(username)")
func channel(_ id: String) -> Resource {
resource(baseURL: account.url, path: "channel/\(id)")
}
func channelVideos(_ id: String) -> Resource {
channel(id, contentType: .videos)
channel(id)
}
func channelPlaylist(_ id: String) -> Resource? {
@@ -271,7 +161,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
}
var signedIn: Bool {
guard let account else {
guard let account = account else {
return false
}
@@ -282,7 +172,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
resource(baseURL: account.instance.apiURL, path: "subscriptions")
}
func feed(_: Int?) -> Resource? {
var feed: Resource? {
resource(baseURL: account.instance.apiURL, path: "feed")
.withParam("authToken", account.token)
}
@@ -395,10 +285,11 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
private func extractContentItem(from content: JSON) -> ContentItem? {
let details = content.dictionaryValue
let url: String! = details["url"]?.string
let contentType: ContentItem.ContentType
if let url = details["url"]?.string {
if !url.isNil {
if url.contains("/playlist") {
contentType = .playlist
} else if url.contains("/channel") {
@@ -425,7 +316,6 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
if let channel = extractChannel(from: content) {
return ContentItem(channel: channel)
}
default:
return nil
}
@@ -439,113 +329,62 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
private func extractChannel(from content: JSON) -> Channel? {
let attributes = content.dictionaryValue
guard let id = attributes["id"]?.string ??
(attributes["url"] ?? attributes["uploaderUrl"])?.string?.components(separatedBy: "/").last
guard let id = attributes["id"]?.stringValue ??
(attributes["url"] ?? attributes["uploaderUrl"])?.stringValue.components(separatedBy: "/").last
else {
return nil
}
let subscriptionsCount = attributes["subscriberCount"]?.int ?? attributes["subscribers"]?.int
let subscriptionsCount = attributes["subscriberCount"]?.intValue ?? attributes["subscribers"]?.intValue
var videos = [Video]()
if let relatedStreams = attributes["relatedStreams"] {
videos = extractVideos(from: relatedStreams)
}
let name = attributes["name"]?.string ??
attributes["uploaderName"]?.string ??
attributes["uploader"]?.string ?? ""
let thumbnailURL = attributes["avatarUrl"]?.url ??
attributes["uploaderAvatar"]?.url ??
attributes["avatar"]?.url ??
attributes["thumbnail"]?.url
let tabs = attributes["tabs"]?.arrayValue.compactMap { tab in
let name = tab["name"].string
let data = tab["data"].string
if let name, let data, let type = Channel.ContentType(rawValue: name) {
return Channel.Tab(contentType: type, data: data)
}
return nil
} ?? [Channel.Tab]()
let name = attributes["name"]?.stringValue ?? attributes["uploaderName"]?.stringValue ?? attributes["uploader"]?.stringValue ?? ""
let thumbnailURL = attributes["avatarUrl"]?.url ?? attributes["uploaderAvatar"]?.url ?? attributes["avatar"]?.url ?? attributes["thumbnail"]?.url
return Channel(
app: .piped,
id: id,
name: name,
bannerURL: attributes["bannerUrl"]?.url,
thumbnailURL: thumbnailURL,
subscriptionsCount: subscriptionsCount,
verified: attributes["verified"]?.bool,
videos: videos,
tabs: tabs
videos: videos
)
}
func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist? {
let details = json.dictionaryValue
let id = details["url"]?.stringValue.components(separatedBy: "?list=").last
let id = details["url"]?.stringValue.components(separatedBy: "?list=").last ?? UUID().uuidString
let thumbnailURL = details["thumbnail"]?.url ?? details["thumbnailUrl"]?.url
var videos = [Video]()
if let relatedStreams = details["relatedStreams"] {
videos = extractVideos(from: relatedStreams)
}
return ChannelPlaylist(
id: id ?? UUID().uuidString,
title: details["name"]?.string ?? "",
id: id,
title: details["name"]?.stringValue ?? "",
thumbnailURL: thumbnailURL,
channel: extractChannel(from: json),
channel: extractChannel(from: json)!,
videos: videos,
videosCount: details["videos"]?.int
)
}
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
let url = details["url"]?.string
if let url = details["url"]?.string {
guard url.contains("/watch") else {
if !url.isNil {
guard url!.contains("/watch") else {
return nil
}
}
let channelId = details["uploaderUrl"]?.string?.components(separatedBy: "/").last ?? "unknown"
let channelId = details["uploaderUrl"]!.stringValue.components(separatedBy: "/").last!
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)
}
@@ -553,133 +392,86 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
return nil
}
let author = details["uploaderName"]?.string ?? details["uploader"]?.string ?? ""
let author = details["uploaderName"]?.stringValue ?? details["uploader"]!.stringValue
let authorThumbnailURL = details["avatarUrl"]?.url ?? details["uploaderAvatar"]?.url ?? details["avatar"]?.url
let subscriptionsCount = details["uploaderSubscriberCount"]?.int
let uploaded = details["uploaded"]?.double
var published = (uploaded.isNil || uploaded == -1) ? nil : (uploaded! / 1000).formattedAsRelativeTime()
var publishedAt: Date?
let dateFormatter = ISO8601DateFormatter()
dateFormatter.formatOptions = [.withInternetDateTime]
if published.isNil,
let date = details["uploadDate"]?.string,
let formattedDate = dateFormatter.date(from: date)
{
publishedAt = formattedDate
published = ""
} else if published.isNil {
published = (details["uploadedDate"] ?? details["uploadDate"])?.string ?? ""
let uploaded = details["uploaded"]?.doubleValue
var published = uploaded.isNil ? nil : (uploaded! / 1000).formattedAsRelativeTime()
if published.isNil {
published = (details["uploadedDate"] ?? details["uploadDate"])?.stringValue ?? ""
}
let live = details["livestream"]?.bool ?? (details["duration"]?.int == -1)
let description = extractDescription(from: content) ?? ""
var chapters = extractChapters(from: content)
if chapters.isEmpty, !description.isEmpty {
chapters = extractChapters(from: description)
}
let length = details["duration"]?.double ?? 0
let live = details["livestream"]?.boolValue ?? (details["duration"]?.intValue == -1)
return Video(
instanceID: account.instanceID,
app: .piped,
instanceURL: account.instance.apiURL,
videoID: extractID(from: content),
title: details["title"]?.string ?? "",
title: details["title"]!.stringValue,
author: author,
length: length,
published: published ?? "",
views: details["views"]?.int ?? 0,
description: description,
channel: Channel(app: .piped, id: channelId, name: author, thumbnailURL: authorThumbnailURL, subscriptionsCount: subscriptionsCount),
length: details["duration"]!.doubleValue,
published: published!,
views: details["views"]!.intValue,
description: extractDescription(from: content),
channel: Channel(id: channelId, name: author, thumbnailURL: authorThumbnailURL, subscriptionsCount: subscriptionsCount),
thumbnails: thumbnails,
live: live,
short: details["isShort"]?.bool ?? (length <= Video.shortLength),
publishedAt: publishedAt,
likes: details["likes"]?.int,
dislikes: details["dislikes"]?.int,
streams: extractStreams(from: content),
related: extractRelated(from: content),
chapters: extractChapters(from: content),
captions: extractCaptions(from: content)
related: extractRelated(from: content)
)
}
private func extractID(from content: JSON) -> Video.ID {
content.dictionaryValue["url"]?.string?.components(separatedBy: "?v=").last ??
extractThumbnailURL(from: content)?.relativeString.components(separatedBy: "/")[4] ?? ""
content.dictionaryValue["url"]?.stringValue.components(separatedBy: "?v=").last ??
extractThumbnailURL(from: content)!.relativeString.components(separatedBy: "/")[4]
}
private func extractThumbnailURL(from content: JSON) -> URL? {
content.dictionaryValue["thumbnail"]?.url ?? content.dictionaryValue["thumbnailUrl"]?.url
content.dictionaryValue["thumbnail"]?.url! ?? content.dictionaryValue["thumbnailUrl"]!.url!
}
private func buildThumbnailURL(from content: JSON, quality: Thumbnail.Quality) -> URL? {
guard let thumbnailURL = extractThumbnailURL(from: content) else {
let thumbnailURL = extractThumbnailURL(from: content)
guard !thumbnailURL.isNil else {
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)
)!
}
private func extractUserPlaylist(from json: JSON) -> Playlist? {
let id = json["id"].string ?? ""
let title = json["name"].string ?? ""
let id = json["id"].stringValue
let title = json["name"].stringValue
let visibility = Playlist.Visibility.private
return Playlist(id: id, title: title, visibility: visibility)
}
private func extractDescription(from content: JSON) -> String? {
guard let description = content.dictionaryValue["description"]?.string else { return nil }
guard var description = content.dictionaryValue["description"]?.string else {
return nil
}
return replaceHTML(description)
}
private func replaceHTML(_ string: String) -> String {
var string = string.replacingOccurrences(
description = description.replacingOccurrences(
of: "<br/>|<br />|<br>",
with: "\n",
options: .regularExpression,
range: nil
)
let linkRegex = #"(<a\s+(?:[^>]*?\s+)?href=\"[^"]*\">[^<]*<\/a>)"#
let hrefRegex = #"href=\"([^"]*)\">"#
guard let hrefRegex = try? NSRegularExpression(pattern: hrefRegex) else { return string }
string = string.replacingMatches(regex: linkRegex) { matchingGroup in
let results = hrefRegex.matches(in: matchingGroup, range: NSRange(matchingGroup.startIndex..., in: matchingGroup))
description = description.replacingOccurrences(
of: "<[^>]+>",
with: "",
options: .regularExpression,
range: nil
)
if let result = results.first {
if let swiftRange = Range(result.range(at: 1), in: matchingGroup) {
return String(matchingGroup[swiftRange])
}
}
return matchingGroup
}
string = string
.replacingOccurrences(of: "&amp;", with: "&")
.replacingOccurrences(of: "&nbsp;", with: " ")
.replacingOccurrences(
of: "<[^>]+>",
with: "",
options: .regularExpression,
range: nil
)
return string
return description
}
private func extractVideos(from content: JSON) -> [Video] {
@@ -690,125 +482,38 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
var streams = [Stream]()
if let hlsURL = content.dictionaryValue["hls"]?.url {
streams.append(Stream(instance: account.instance, hlsURL: hlsURL))
streams.append(Stream(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 { $0.dictionaryValue["format"]?.stringValue == "M4A" }
.sorted {
$0.dictionaryValue["bitrate"]?.int ?? 0 >
$1.dictionaryValue["bitrate"]?.int ?? 0
$0.dictionaryValue["bitrate"]?.intValue ?? 0 > $1.dictionaryValue["bitrate"]?.intValue ?? 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 {
let videoCodec = videoStream.dictionaryValue["codec"]?.string ?? ""
if Self.disallowedVideoCodecs.contains(where: videoCodec.contains) {
continue
}
videoStreams.forEach { videoStream in
let audioAsset = AVURLAsset(url: audioStream.dictionaryValue["url"]!.url!)
let videoAsset = AVURLAsset(url: videoStream.dictionaryValue["url"]!.url!)
guard let videoAssetUrl = videoStream.dictionaryValue["url"]?.url else {
continue
}
// Use the first (ORIGINAL) audio track as default
let defaultAudioAsset = AVURLAsset(url: audioTracks[0].url)
let videoAsset = AVURLAsset(url: videoAssetUrl)
let videoOnly = videoStream.dictionaryValue["videoOnly"]?.bool ?? true
let quality = videoStream.dictionaryValue["quality"]?.string ?? "unknown"
let qualityComponents = quality.components(separatedBy: "p")
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
}
let videoOnly = videoStream.dictionaryValue["videoOnly"]?.boolValue ?? true
let resolution = Stream.Resolution.from(resolution: videoStream.dictionaryValue["quality"]!.stringValue)
let videoFormat = videoStream.dictionaryValue["format"]?.stringValue
if videoOnly {
streams.append(
Stream(
instance: account.instance,
audioAsset: defaultAudioAsset,
videoAsset: videoAsset,
resolution: resolution,
kind: .adaptive,
videoFormat: videoFormat,
bitrate: bitrate,
requestRange: requestRange,
audioTracks: audioTracks
)
Stream(audioAsset: audioAsset, videoAsset: videoAsset, resolution: resolution, kind: .adaptive, videoFormat: videoFormat)
)
} else {
streams.append(
SingleAssetStream(
instance: account.instance,
avAsset: videoAsset,
resolution: resolution,
kind: .stream
)
SingleAssetStream(avAsset: videoAsset, resolution: resolution, kind: .stream)
)
}
}
@@ -825,80 +530,20 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
private func extractComment(from content: JSON) -> Comment? {
let details = content.dictionaryValue
let author = details["author"]?.string ?? ""
let commentorUrl = details["commentorUrl"]?.string
let author = details["author"]?.stringValue ?? ""
let commentorUrl = details["commentorUrl"]?.stringValue
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"]?.stringValue ?? 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,
repliesPage: details["repliesPage"]?.string,
channel: Channel(app: .piped, id: channelId, name: author)
authorAvatarURL: details["thumbnail"]?.stringValue ?? "",
time: details["commentedTime"]?.stringValue ?? "",
pinned: details["pinned"]?.boolValue ?? false,
hearted: details["hearted"]?.boolValue ?? false,
likeCount: details["likeCount"]?.intValue ?? 0,
text: details["commentText"]?.stringValue ?? "",
repliesPage: details["repliesPage"]?.stringValue,
channel: Channel(id: channelId, name: author)
)
}
private func extractCommentText(from string: String?) -> String {
guard let string, !string.isEmpty else { return "" }
return replaceHTML(string)
}
private func extractChapters(from content: JSON) -> [Chapter] {
guard let chapters = content.dictionaryValue["chapters"]?.array else {
return .init()
}
return chapters.compactMap { chapter in
guard let title = chapter["title"].string,
let image = chapter["image"].url,
let start = chapter["start"].double
else {
return nil
}
return Chapter(title: title, image: image, start: start)
}
}
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]
{
return items
}
return .null
}
}

View File

@@ -6,11 +6,7 @@ protocol VideosAPI {
var account: Account! { get }
var signedIn: Bool { get }
static func withAnonymousAccountForInstanceURL(_ url: URL) -> Self
func channel(_ id: String, contentType: Channel.ContentType, data: String?, page: String?) -> Resource
func channelByName(_ name: String) -> Resource?
func channelByUsername(_ username: String) -> Resource?
func channel(_ id: String) -> Resource
func channelVideos(_ id: String) -> Resource
func trending(country: Country, category: TrendingCategory?) -> Resource
func search(_ query: SearchQuery, page: String?) -> Resource
@@ -18,8 +14,8 @@ protocol VideosAPI {
func video(_ id: Video.ID) -> Resource
func feed(_ page: Int?) -> Resource?
var subscriptions: Resource? { get }
var feed: Resource? { get }
var home: Resource? { get }
var popular: Resource? { get }
var playlists: Resource? { get }
@@ -61,67 +57,40 @@ protocol VideosAPI {
func channelPlaylist(_ id: String) -> Resource?
func loadDetails(
_ item: PlayerQueueItem,
failureHandler: ((RequestError) -> Void)?,
completionHandler: @escaping (PlayerQueueItem) -> Void
)
func shareURL(_ item: ContentItem, frontendURLString: String?, time: CMTime?) -> URL?
func loadDetails(_ item: PlayerQueueItem, completionHandler: @escaping (PlayerQueueItem) -> Void)
func shareURL(_ item: ContentItem, frontendHost: String?, time: CMTime?) -> URL?
func comments(_ id: Video.ID, page: String?) -> Resource?
}
extension VideosAPI {
func channel(_ id: String, contentType: Channel.ContentType, data: String? = nil, page: String? = nil) -> Resource {
channel(id, contentType: contentType, data: data, page: page)
}
func loadDetails(
_ item: PlayerQueueItem,
failureHandler: ((RequestError) -> Void)? = nil,
completionHandler: @escaping (PlayerQueueItem) -> Void = { _ in }
) {
func loadDetails(_ item: PlayerQueueItem, completionHandler: @escaping (PlayerQueueItem) -> Void = { _ in }) {
guard (item.video?.streams ?? []).isEmpty else {
completionHandler(item)
return
}
if let video = item.video, video.isLocal {
completionHandler(item)
return
}
video(item.videoID).load()
.onSuccess { response in
guard let video: Video = response.typedContent() else {
return
}
VideosCacheModel.shared.storeVideo(video)
var newItem = item
newItem.id = UUID()
newItem.video = video
completionHandler(newItem)
video(item.videoID).load().onSuccess { response in
guard let video: Video = response.typedContent() else {
return
}
.onFailure { failureHandler?($0) }
var newItem = item
newItem.video = video
completionHandler(newItem)
}
}
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 {
@@ -147,101 +116,4 @@ extension VideosAPI {
return urlComponents.url
}
func extractChapters(from description: String) -> [Chapter] {
/*
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"
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.
*/
let patterns = [
"(?<=\\n|^)\\s*(?:►\\s*)?\\[?(?<start>(?:[0-9]+:){1,2}[0-9]+)\\]?(?:\\s*-\\s*)?(?<end>(?:[0-9]+:){1,2}[0-9]+)?(?:\\s*-\\s*|\\s*[:]\\s*)?(?<title>.*)(?=\\n|$)",
"(?<=\\n|^)\\s*(?:►\\s*)?\\[?(?<start>(?:[0-9]+:){1,2}[0-9]+)\\]?\\s*[-:]?\\s*(?<title>.+)(?=\\n|$)",
"(?<=\\n|^)(?<index>[0-9]+\\.\\s)(?<title>.+?)(?:\\s*-\\s*)?(?<start>(?:[0-9]+:){1,2}[0-9]+)(?=\\n|$)",
"(?<=\\n|^)(?<title>.+?):\\s*\\((?<start>(?:[0-9]+:){1,2}[0-9]+)\\)(?=\\n|$)",
"(?<=^|\\n)\\((?<start>(?:[0-9]+:){1,2}[0-9]+)\\)\\s*(?<title>.+?)(?=\\n|$)"
]
let extractChaptersGroup = DispatchGroup()
var capturedChapters: [Int: [Chapter]] = [:]
let lock = NSLock()
for (index, pattern) in patterns.enumerated() {
extractChaptersGroup.enter()
DispatchQueue.global().async {
if let chaptersRegularExpression = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) {
let chapterLines = chaptersRegularExpression.matches(in: description, range: NSRange(description.startIndex..., in: description))
let extractedChapters = chapterLines.compactMap { line -> Chapter? in
let titleRange = line.range(withName: "title")
let 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 }
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 !extractedChapters.isEmpty {
lock.lock()
capturedChapters[index] = extractedChapters
lock.unlock()
}
}
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 []
}
}

View File

@@ -1,41 +1,18 @@
import Foundation
enum VideosApp: String, CaseIterable {
enum AppType: String {
case local
case youTube
case peerTube
}
case local
case invidious
case piped
case peerTube
case invidious, piped
var name: String {
switch self {
case .peerTube:
return "PeerTube"
default:
return rawValue.capitalized
}
}
var appType: AppType {
switch self {
case .local:
return .local
case .invidious:
return .youTube
case .piped:
return .youTube
case .peerTube:
return .peerTube
}
rawValue.capitalized
}
var supportsAccounts: Bool {
self != .local
true
}
var accountsUsePassword: Bool {
self == .piped
}
var supportsPopular: Bool {
@@ -46,34 +23,22 @@ enum VideosApp: String, CaseIterable {
self == .invidious
}
var supportsSearchSuggestions: Bool {
self != .peerTube
}
var supportsSubscriptions: Bool {
supportsAccounts
}
var paginatesSubscriptions: Bool {
self == .invidious
}
var supportsTrendingCategories: Bool {
self == .invidious
}
var supportsUserPlaylists: Bool {
self != .local
true
}
var userPlaylistsEndpointIncludesVideos: Bool {
self == .invidious
}
var userPlaylistsUseChannelPlaylistEndpoint: Bool {
self == .piped
}
var userPlaylistsHaveVisibility: Bool {
self == .invidious
}
@@ -86,19 +51,11 @@ enum VideosApp: String, CaseIterable {
self == .piped
}
var searchUsesIndexedPages: Bool {
self == .invidious
}
var supportsOpeningChannelsByName: Bool {
var supportsComments: Bool {
self == .piped
}
var allowsDisablingVidoesProxying: Bool {
self == .invidious || self == .piped
}
var supportsOpeningVideosByID: Bool {
self != .local
var searchUsesIndexedPages: Bool {
self == .invidious
}
}

View File

@@ -1,41 +0,0 @@
import Cache
import Foundation
import Logging
import SwiftyJSON
struct BaseCacheModel {
static var shared = Self()
static let jsonToDataTransformer: (JSON) -> Data = { try! $0.rawData() }
static let jsonFromDataTransformer: (Data) -> JSON = { try! JSON(data: $0) }
static let jsonTransformer = Transformer(toData: jsonToDataTransformer, fromData: jsonFromDataTransformer)
static let imageCache = URLCache(memoryCapacity: 512 * 1000 * 1000, diskCapacity: 10 * 1000 * 1000 * 1000)
var models: [CacheModel] {
[
FeedCacheModel.shared,
VideosCacheModel.shared,
ChannelsCacheModel.shared,
PlaylistsCacheModel.shared,
ChannelPlaylistsCacheModel.shared,
SubscribedChannelsModel.shared
]
}
func clear() {
models.forEach { $0.clear() }
Self.imageCache.removeAllCachedResponses()
}
var totalSize: Int {
models.compactMap { $0.storage?.totalDiskStorageSize }.reduce(0, +) + Self.imageCache.currentDiskUsage
}
var totalSizeFormatted: String {
byteCountFormatter.string(fromByteCount: Int64(totalSize))
}
private var byteCountFormatter: ByteCountFormatter { .init() }
}

View File

@@ -1,17 +0,0 @@
import Cache
import Foundation
import Logging
import SwiftyJSON
struct BookmarksCacheModel {
static var shared = Self()
let logger = Logger(label: "stream.yattee.cache")
static let bookmarksGroup = "group.stream.yattee.app.bookmarks"
let defaults = UserDefaults(suiteName: Self.bookmarksGroup)
func clear() {
guard let defaults else { return }
defaults.dictionaryRepresentation().keys.forEach(defaults.removeObject(forKey:))
}
}

View File

@@ -1,41 +0,0 @@
import Cache
import Foundation
import SwiftyJSON
protocol CacheModel {
var storage: Storage<String, JSON>? { get }
func clear()
}
extension CacheModel {
func clear() {
try? storage?.removeAll()
}
func getFormattedDate(_ date: Date?) -> String {
guard let date else { return "unknown" }
let isSameDay = Calendar(identifier: .iso8601).isDate(date, inSameDayAs: Date())
let formatter = isSameDay ? dateFormatterForTimeOnly : dateFormatter
return formatter.string(from: date)
}
var dateFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .medium
return formatter
}
var dateFormatterForTimeOnly: DateFormatter {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .medium
return formatter
}
var iso8601DateFormatter: ISO8601DateFormatter { .init() }
}

View File

@@ -1,57 +0,0 @@
import Cache
import Foundation
import Logging
import SwiftyJSON
struct ChannelPlaylistsCacheModel: CacheModel {
static let shared = Self()
let logger = Logger(label: "stream.yattee.cache.channel-playlists")
static let diskConfig = DiskConfig(name: "channel-playlists")
static let memoryConfig = MemoryConfig()
var storage = try? Storage<String, JSON>(
diskConfig: Self.diskConfig,
memoryConfig: Self.memoryConfig,
fileManager: FileManager.default,
transformer: BaseCacheModel.jsonTransformer
)
func storePlaylist(playlist: ChannelPlaylist) {
let date = iso8601DateFormatter.string(from: Date())
logger.info("STORE \(playlist.cacheKey) -- \(date)")
let feedTimeObject: JSON = ["date": date]
let playlistObject: JSON = ["playlist": playlist.json.object]
try? storage?.setObject(feedTimeObject, forKey: playlistTimeCacheKey(playlist.cacheKey))
try? storage?.setObject(playlistObject, forKey: playlist.cacheKey)
}
func retrievePlaylist(_ playlist: ChannelPlaylist) -> ChannelPlaylist? {
logger.info("RETRIEVE \(playlist.cacheKey)")
if let json = try? storage?.object(forKey: playlist.cacheKey).dictionaryValue["playlist"] {
return ChannelPlaylist.from(json)
}
return nil
}
func getPlaylistsTime(_ id: ChannelPlaylist.ID) -> Date? {
if let json = try? storage?.object(forKey: playlistTimeCacheKey(id)),
let string = json.dictionaryValue["date"]?.string,
let date = iso8601DateFormatter.date(from: string)
{
return date
}
return nil
}
func getFormattedPlaylistTime(_ id: ChannelPlaylist.ID) -> String {
getFormattedDate(getPlaylistsTime(id))
}
private func playlistTimeCacheKey(_ cacheKey: ChannelPlaylist.ID) -> String {
"\(cacheKey)-time"
}
}

View File

@@ -1,47 +0,0 @@
import Cache
import Foundation
import Logging
import SwiftyJSON
struct ChannelsCacheModel: CacheModel {
static let shared = Self()
let logger = Logger(label: "stream.yattee.cache.channels")
static let diskConfig = DiskConfig(name: "channels")
static let memoryConfig = MemoryConfig()
let storage = try? Storage<String, JSON>(
diskConfig: Self.diskConfig,
memoryConfig: Self.memoryConfig,
fileManager: FileManager.default,
transformer: BaseCacheModel.jsonTransformer
)
func store(_ channel: Channel) {
guard channel.hasExtendedDetails else {
logger.debug("not caching \(channel.cacheKey)")
return
}
logger.info("caching \(channel.cacheKey)")
try? storage?.setObject(channel.json, forKey: channel.cacheKey)
}
func storeIfMissing(_ channel: Channel) {
guard let storage, !storage.objectExists(forKey: channel.cacheKey) else {
return
}
store(channel)
}
func retrieve(_ cacheKey: String) -> ChannelPage? {
logger.debug("retrieving cache for \(cacheKey)")
if let json = try? storage?.object(forKey: cacheKey) {
return ChannelPage(channel: Channel.from(json))
}
return nil
}
}

View File

@@ -1,67 +0,0 @@
import Cache
import Defaults
import Foundation
import Logging
import SwiftyJSON
struct FeedCacheModel: CacheModel {
static let shared = Self()
let logger = Logger(label: "stream.yattee.cache.feed")
static let diskConfig = DiskConfig(name: "feed")
static let memoryConfig = MemoryConfig()
let storage = try? Storage<String, JSON>(
diskConfig: Self.diskConfig,
memoryConfig: Self.memoryConfig,
fileManager: FileManager.default,
transformer: BaseCacheModel.jsonTransformer
)
func storeFeed(account: Account, videos: [Video]) {
DispatchQueue.global(qos: .background).async {
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)]
try? storage?.setObject(feedTimeObject, forKey: feedTimeCacheKey(account.feedCacheKey))
try? storage?.setObject(videosObject, forKey: account.feedCacheKey)
}
}
func retrieveFeed(account: Account) -> [Video] {
logger.debug("retrieving cache for \(account.feedCacheKey)")
if let json = try? storage?.object(forKey: account.feedCacheKey),
let videos = json.dictionaryValue["videos"]
{
return videos.arrayValue.map { Video.from($0) }
}
return []
}
func getFeedTime(account: Account) -> Date? {
if let json = try? storage?.object(forKey: feedTimeCacheKey(account.feedCacheKey)),
let string = json.dictionaryValue["date"]?.string,
let date = iso8601DateFormatter.date(from: string)
{
return date
}
return nil
}
private var cacheLimit: Int {
let setting = Int(Defaults[.feedCacheSize]) ?? 0
if setting > 0 {
return setting
}
return 50
}
private func feedTimeCacheKey(_ feedCacheKey: String) -> String {
"\(feedCacheKey)-feedTime"
}
}

View File

@@ -1,64 +0,0 @@
import Cache
import Foundation
import Logging
import SwiftyJSON
struct PlaylistsCacheModel: CacheModel {
static let shared = Self()
static let limit = 30
let logger = Logger(label: "stream.yattee.cache.playlists")
static let diskConfig = DiskConfig(name: "playlists")
static let memoryConfig = MemoryConfig()
let storage = try? Storage<String, JSON>(
diskConfig: Self.diskConfig,
memoryConfig: Self.memoryConfig,
fileManager: FileManager.default,
transformer: BaseCacheModel.jsonTransformer
)
func storePlaylist(account: Account, playlists: [Playlist]) {
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)]
try? storage?.setObject(feedTimeObject, forKey: playlistTimeCacheKey(account))
try? storage?.setObject(playlistsObject, forKey: playlistCacheKey(account))
}
func retrievePlaylists(account: Account) -> [Playlist] {
logger.debug("retrieving cache for \(playlistCacheKey(account))")
if let json = try? storage?.object(forKey: playlistCacheKey(account)),
let playlists = json.dictionaryValue["playlists"]
{
return playlists.arrayValue.map { Playlist.from($0) }
}
return []
}
func getPlaylistsTime(account: Account) -> Date? {
if let json = try? storage?.object(forKey: playlistTimeCacheKey(account)),
let string = json.dictionaryValue["date"]?.string,
let date = iso8601DateFormatter.date(from: string)
{
return date
}
return nil
}
func getFormattedPlaylistTime(account: Account) -> String {
getFormattedDate(getPlaylistsTime(account: account))
}
private func playlistCacheKey(_ account: Account) -> String {
"playlists-\(account.id)"
}
private func playlistTimeCacheKey(_ account: Account) -> String {
"\(playlistCacheKey(account))-time"
}
}

View File

@@ -1,184 +0,0 @@
import Cache
import Foundation
import Logging
import Siesta
import SwiftUI
import SwiftyJSON
final class SubscribedChannelsModel: ObservableObject, CacheModel {
static var shared = SubscribedChannelsModel()
let logger = Logger(label: "stream.yattee.cache.channels")
static let diskConfig = DiskConfig(name: "channels")
static let memoryConfig = MemoryConfig()
let storage = try? Storage<String, JSON>(
diskConfig: SubscribedChannelsModel.diskConfig,
memoryConfig: SubscribedChannelsModel.memoryConfig,
fileManager: FileManager.default,
transformer: BaseCacheModel.jsonTransformer
)
@Published var isLoading = false
@Published var channels = [Channel]()
@Published var error: RequestError?
var accounts: AccountsModel { .shared }
var unwatchedFeedCount: UnwatchedFeedCountModel { .shared }
var resource: Resource? {
accounts.api.subscriptions
}
var all: [Channel] {
channels.sorted { $0.name.lowercased() < $1.name.lowercased() }
}
var allByUnwatchedCount: [Channel] {
if let account = accounts.current {
return all.sorted { c1, c2 in
let c1HasUnwatched = (unwatchedFeedCount.unwatchedByChannel[account]?[c1.id] ?? -1) > 0
let c2HasUnwatched = (unwatchedFeedCount.unwatchedByChannel[account]?[c2.id] ?? -1) > 0
let nameIncreasing = c1.name.lowercased() < c2.name.lowercased()
return c1HasUnwatched ? (c2HasUnwatched ? nameIncreasing : true) : (c2HasUnwatched ? false : nameIncreasing)
}
}
return all
}
func subscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) {
accounts.api.subscribe(channelID) {
self.scheduleLoad(onSuccess: onSuccess)
}
}
func unsubscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) {
accounts.api.unsubscribe(channelID) {
self.scheduleLoad(onSuccess: onSuccess)
}
}
func isSubscribing(_ channelID: String) -> Bool {
channels.contains { $0.id == channelID }
}
func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) {
guard accounts.app.supportsSubscriptions, !isLoading, accounts.signedIn, let account = accounts.current else {
channels = []
return
}
DispatchQueue.main.async { [weak self] in
guard let self else { return }
let request = force ? self.resource?.load() : self.resource?.loadIfNeeded()
guard request != nil else { return }
self.loadCachedChannels(account)
self.isLoading = true
request?
.onCompletion { [weak self] _ in
self?.isLoading = false
}
.onSuccess { resource in
self.error = nil
if let channels: [Channel] = resource.typedContent() {
self.channels = channels
self.storeChannels(account: account, channels: channels)
FeedModel.shared.calculateUnwatchedFeed()
onSuccess()
}
}
.onFailure { self.error = $0 }
}
}
func loadCachedChannels(_ account: Account) {
let cache = getChannels(account: account)
if !cache.isEmpty {
DispatchQueue.main.async {
self.channels = cache
}
}
}
func storeChannels(account: Account, channels: [Channel]) {
DispatchQueue.global(qos: .background).async {
let date = self.iso8601DateFormatter.string(from: Date())
self.logger.info("caching channels \(self.channelsDateCacheKey(account)) -- \(date)")
channels.forEach { ChannelsCacheModel.shared.storeIfMissing($0) }
let dateObject: JSON = ["date": date]
let channelsObject: JSON = ["channels": channels.map(\.json).map(\.object)]
try? self.storage?.setObject(dateObject, forKey: self.channelsDateCacheKey(account))
try? self.storage?.setObject(channelsObject, forKey: self.channelsCacheKey(account))
}
}
func getChannels(account: Account) -> [Channel] {
logger.info("getting channels \(channelsDateCacheKey(account))")
if let json = try? storage?.object(forKey: channelsCacheKey(account)),
let channels = json.dictionaryValue["channels"]
{
return channels.arrayValue.compactMap { json in
let channel = Channel.from(json)
if !channel.hasExtendedDetails,
let cache = ChannelsCacheModel.shared.retrieve(channel.cacheKey)
{
return cache.channel
}
return channel
}
}
return []
}
private func scheduleLoad(onSuccess: @escaping () -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.load(force: true, onSuccess: onSuccess)
}
}
private func channelsCacheKey(_ account: Account) -> String {
"channels-\(account.id)"
}
private func channelsDateCacheKey(_ account: Account) -> String {
"channels-\(account.id)-date"
}
func getChannelsTime(account: Account) -> Date? {
if let json = try? storage?.object(forKey: channelsDateCacheKey(account)),
let string = json.dictionaryValue["date"]?.string,
let date = iso8601DateFormatter.date(from: string)
{
return date
}
return nil
}
var channelsTime: Date? {
if let account = accounts.current {
return getChannelsTime(account: account)
}
return nil
}
var formattedCacheTime: String {
getFormattedDate(channelsTime)
}
func onAccountChange() {
channels = []
load(force: true)
}
}

View File

@@ -1,36 +0,0 @@
import Cache
import Foundation
import Logging
import SwiftyJSON
struct VideosCacheModel: CacheModel {
static let shared = Self()
let logger = Logger(label: "stream.yattee.cache.videos")
static let diskConfig = DiskConfig(name: "videos")
static let memoryConfig = MemoryConfig()
let storage = try? Storage<String, JSON>(
diskConfig: Self.diskConfig,
memoryConfig: Self.memoryConfig,
fileManager: FileManager.default,
transformer: BaseCacheModel.jsonTransformer
)
func storeVideo(_ video: Video) {
logger.info("caching \(video.cacheKey)")
try? storage?.setObject(video.json, forKey: video.cacheKey)
ChannelsCacheModel.shared.storeIfMissing(video.channel)
}
func retrieveVideo(_ cacheKey: String) -> Video? {
logger.debug("retrieving cache for \(cacheKey)")
if let json = try? storage?.object(forKey: cacheKey) {
return Video.from(json)
}
return nil
}
}

View File

@@ -1,12 +0,0 @@
import Foundation
struct Captions: Hashable, Identifiable {
var id = UUID().uuidString
let label: String
let code: String
let url: URL
var description: String {
"\(label) (\(code))"
}
}

View File

@@ -4,170 +4,43 @@ import Foundation
import SwiftyJSON
struct Channel: Identifiable, Hashable {
enum ContentType: String, Identifiable, CaseIterable {
case videos
case playlists
case livestreams
case shorts
case channels
case releases
case podcasts
static func from(_ name: String) -> Self? {
let rawValueMatch = allCases.first { $0.rawValue == name }
guard rawValueMatch.isNil else { return rawValueMatch! }
if name == "streams" { return .livestreams }
return nil
}
var id: String {
rawValue
}
var description: String {
switch self {
case .livestreams:
return "Live Streams".localized()
default:
return rawValue.capitalized.localized()
}
}
var systemImage: String {
switch self {
case .videos:
return "video"
case .playlists:
return "list.and.film"
case .livestreams:
return "dot.radiowaves.left.and.right"
case .shorts:
return "1.square"
case .channels:
return "person.3"
case .releases:
return "square.stack"
case .podcasts:
return "radio"
}
}
var alwaysAvailable: Bool {
self == .videos || self == .playlists
}
}
struct Tab: Identifiable, Hashable {
var contentType: ContentType
var data: String
var id: String {
contentType.id
}
}
var app: VideosApp
var instanceID: Instance.ID?
var instanceURL: URL?
var id: String
var name: String
var bannerURL: URL?
var thumbnailURL: URL?
var description = ""
var subscriptionsCount: Int?
var subscriptionsText: String?
var totalViews: Int?
// swiftlint:disable discouraged_optional_boolean
var verified: Bool?
// swiftlint:enable discouraged_optional_boolean
var videos = [Video]()
var tabs = [Tab]()
private var subscriptionsCount: Int?
private var subscriptionsText: String?
init(
id: String,
name: String,
thumbnailURL: URL? = nil,
subscriptionsCount: Int? = nil,
subscriptionsText: String? = nil,
videos: [Video] = []
) {
self.id = id
self.name = name
self.thumbnailURL = thumbnailURL
self.subscriptionsCount = subscriptionsCount
self.subscriptionsText = subscriptionsText
self.videos = videos
}
var detailsLoaded: Bool {
!subscriptionsString.isNil
}
var subscriptionsString: String? {
if let subscriptionsCount, subscriptionsCount > 0 {
return subscriptionsCount.formattedAsAbbreviation()
if subscriptionsCount != nil, subscriptionsCount! > 0 {
return subscriptionsCount!.formattedAsAbbreviation()
}
return subscriptionsText
}
var totalViewsString: String? {
guard let totalViews, totalViews > 0 else { return nil }
return totalViews.formattedAsAbbreviation()
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
var contentItem: ContentItem {
ContentItem(channel: self)
}
func hasData(for contentType: ContentType) -> Bool {
tabs.contains { $0.contentType == contentType }
}
var cacheKey: String {
switch app {
case .local:
return id
case .invidious:
return "youtube-\(id)"
case .piped:
return "youtube-\(id)"
case .peerTube:
return "peertube-\(instanceURL?.absoluteString ?? "unknown-instance")-\(id)"
}
}
var hasExtendedDetails: Bool {
thumbnailURL != nil
}
var thumbnailURLOrCached: URL? {
thumbnailURL ?? ChannelsCacheModel.shared.retrieve(cacheKey)?.channel?.thumbnailURL
}
var json: JSON {
[
"app": app.rawValue,
"id": id,
"name": name,
"bannerURL": bannerURL?.absoluteString as Any,
"thumbnailURL": thumbnailURL?.absoluteString as Any,
"description": description,
"subscriptionsCount": subscriptionsCount as Any,
"subscriptionsText": subscriptionsText as Any,
"totalViews": totalViews as Any,
"verified": verified as Any,
"videos": videos.map(\.json.object)
]
}
static func from(_ json: JSON) -> Self {
.init(
app: VideosApp(rawValue: json["app"].stringValue) ?? .local,
id: json["id"].stringValue,
name: json["name"].stringValue,
bannerURL: json["bannerURL"].url,
thumbnailURL: json["thumbnailURL"].url,
description: json["description"].stringValue,
subscriptionsCount: json["subscriptionsCount"].int,
subscriptionsText: json["subscriptionsText"].string,
totalViews: json["totalViews"].int,
videos: json["videos"].arrayValue.map { Video.from($0) }
)
}
}

View File

@@ -1,8 +0,0 @@
import Foundation
struct ChannelPage {
var results = [ContentItem]()
var channel: Channel?
var nextPage: String?
var last = false
}

View File

@@ -1,37 +1,10 @@
import Foundation
import SwiftyJSON
struct ChannelPlaylist: Identifiable {
var id: String
var id: String = UUID().uuidString
var title: String
var thumbnailURL: URL?
var channel: Channel?
var videos = [Video]()
var videosCount: Int?
var cacheKey: String {
"channelplaylists-\(id)"
}
var json: JSON {
[
"id": id,
"title": title,
"thumbnailURL": thumbnailURL?.absoluteString ?? "",
"channel": channel?.json.object ?? "",
"videos": videos.map(\.json.object),
"videosCount": String(videosCount ?? 0)
]
}
static func from(_ json: JSON) -> Self {
Self(
id: json["id"].stringValue,
title: json["title"].stringValue,
thumbnailURL: json["thumbnailURL"].url,
channel: Channel.from(json["channel"]),
videos: json["videos"].arrayValue.map { Video.from($0) },
videosCount: json["videosCount"].int
)
}
}

View File

@@ -1,8 +0,0 @@
import Foundation
struct Chapter: Identifiable, Equatable {
var id = UUID()
var title: String
var image: URL?
var start: Double
}

View File

@@ -3,8 +3,6 @@ import Foundation
import SwiftyJSON
final class CommentsModel: ObservableObject {
static let shared = CommentsModel()
@Published var all = [Comment]()
@Published var nextPage: String?
@@ -17,41 +15,56 @@ final class CommentsModel: ObservableObject {
@Published var repliesPageID: String?
@Published var repliesLoaded = false
var player = PlayerModel.shared
var accounts = AccountsModel.shared
var player: PlayerModel!
var instance: Instance? {
accounts.current?.instance
static var instance: Instance? {
InstancesModel.find(Defaults[.commentsInstanceID])
}
var api: VideosAPI? {
Self.instance.isNil ? nil : PipedAPI(account: Self.instance!.anonymousAccount)
}
static var enabled: Bool {
!instance.isNil
}
#if !os(tvOS)
static var placement: CommentsPlacement {
Defaults[.commentsPlacement]
}
#endif
var nextPageAvailable: Bool {
!(nextPage?.isEmpty ?? true)
}
func loadIfNeeded() {
guard !loaded else { return }
load()
}
func load(page: String? = nil) {
guard let video = player.currentVideo else { return }
guard firstPage || nextPageAvailable else { return }
guard Self.enabled else {
return
}
player
.playerAPI(video)?
.comments(video.videoID, page: page)?
guard !Self.instance.isNil,
!(player?.currentVideo.isNil ?? true)
else {
return
}
if !firstPage && !nextPageAvailable {
return
}
firstPage = page.isNil || page!.isEmpty
api?.comments(player.currentVideo!.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
}
.onCompletion { [weak self] _ in
self?.loaded = true
}
@@ -65,7 +78,6 @@ final class CommentsModel: ObservableObject {
}
func loadNextPage() {
guard nextPageAvailable else { return }
load(page: nextPage)
}
@@ -82,7 +94,7 @@ final class CommentsModel: ObservableObject {
repliesPageID = page
repliesLoaded = false
accounts.api.comments(player.currentVideo!.videoID, page: page)?
api?.comments(player.currentVideo!.videoID, page: page)?
.load()
.onSuccess { [weak self] response in
if let page: CommentsPage = response.typedContent() {

View File

@@ -15,51 +15,26 @@ struct ContentItem: Identifiable {
}
}
static func < (lhs: Self, rhs: Self) -> Bool {
static func < (lhs: ContentType, rhs: ContentType) -> Bool {
lhs.sortOrder < rhs.sortOrder
}
}
static var placeholders: [Self] {
(0 ..< 9).map { i in .init(id: String(i)) }
}
var video: Video!
var playlist: ChannelPlaylist!
var channel: Channel!
var id: String = UUID().uuidString
static func array(of videos: [Video]) -> [Self] {
videos.map { Self(video: $0) }
static func array(of videos: [Video]) -> [ContentItem] {
videos.map { ContentItem(video: $0) }
}
static func array(of playlists: [ChannelPlaylist]) -> [Self] {
playlists.map { Self(playlist: $0) }
}
static func array(of channels: [Channel]) -> [Self] {
channels.map { Self(channel: $0) }
}
static func < (lhs: Self, rhs: Self) -> Bool {
static func < (lhs: ContentItem, rhs: ContentItem) -> Bool {
lhs.contentType < rhs.contentType
}
var contentType: ContentType {
video.isNil ? (channel.isNil ? (playlist.isNil ? .placeholder : .playlist) : .channel) : .video
}
var cacheKey: String {
switch contentType {
case .video:
return video.cacheKey
case .playlist:
return playlist.cacheKey
case .channel:
return channel.cacheKey
case .placeholder:
return id
}
}
}

View File

@@ -215,7 +215,7 @@ extension Country {
case .lk: return "Sri Lanka"
case .se: return "Sweden"
case .ch: return "Switzerland"
case .tw: return "Taiwan"
case .tw: return "Taiwan, Province of China[a]"
case .tz: return "Tanzania, United Republic of"
case .th: return "Thailand"
case .tn: return "Tunisia"
@@ -234,8 +234,6 @@ extension Country {
}
}
// swiftlint:enable switch_case_on_newline
var flag: String {
let unicodeScalars = rawValue
.unicodeScalars
@@ -274,7 +272,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 } }
}

View File

@@ -1,166 +0,0 @@
import Foundation
final class DocumentsModel: ObservableObject {
static var shared = DocumentsModel()
@Published private(set) var refreshID = UUID()
typealias AreInIncreasingOrder = (URL, URL) -> Bool
private var fileManager: FileManager {
.default
}
var sortPredicates: [AreInIncreasingOrder] {
[
{ self.isDirectory($0) && !self.isDirectory($1) },
{ $0.lastPathComponent.caseInsensitiveCompare($1.lastPathComponent) == .orderedAscending }
]
}
func sortedDirectoryContents(_ directoryURL: URL) -> [URL] {
directoryContents(directoryURL).sorted { lhs, rhs in
for predicate in sortPredicates {
if !predicate(lhs, rhs), !predicate(rhs, lhs) {
continue
}
return predicate(lhs, rhs)
}
return false
}
}
func directoryContents(_ directoryURL: URL) -> [URL] {
contents(of: directoryURL)
}
var documentsDirectory: URL? {
if let url = try? fileManager.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) {
return standardizedURL(url)
}
return nil
}
func recentDocuments(_ limit: Int = 10) -> [URL] {
guard let documentsDirectory else { return [] }
return Array(
contents(of: documentsDirectory)
.filter { !isDirectory($0) }
.sorted {
((try? $0.resourceValues(forKeys: [.creationDateKey]).creationDate) ?? Date()) >
((try? $1.resourceValues(forKeys: [.creationDateKey]).creationDate) ?? Date())
}
.prefix(limit)
)
}
func isDocument(_ video: Video) -> Bool {
guard video.isLocal, let url = video.localStream?.localURL, let url = standardizedURL(url) else { return false }
return isDocument(url)
}
func isDocument(_ url: URL) -> Bool {
guard let url = standardizedURL(url), let documentsDirectory else { return false }
return url.absoluteString.starts(with: documentsDirectory.absoluteString)
}
func isDirectory(_ url: URL) -> Bool {
(try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
}
var creationDateFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.setLocalizedDateFormatFromTemplate("YYMMddHHmm")
return formatter
}
func creationDate(_ video: Video) -> Date? {
guard video.isLocal, let url = video.localStream?.localURL, let url = standardizedURL(url) else { return nil }
return creationDate(url)
}
func creationDate(_ url: URL) -> Date? {
try? url.resourceValues(forKeys: [.creationDateKey]).creationDate
}
func formattedCreationDate(_ video: Video) -> String? {
guard video.isLocal, let url = video.localStream?.localURL, let url = standardizedURL(url) else { return nil }
return formattedCreationDate(url)
}
func formattedCreationDate(_ url: URL) -> String? {
if let date = try? url.resourceValues(forKeys: [.creationDateKey]).creationDate {
return creationDateFormatter.string(from: date)
}
return nil
}
var sizeFormatter: ByteCountFormatter {
let formatter = ByteCountFormatter()
formatter.allowedUnits = .useAll
formatter.countStyle = .file
formatter.includesUnit = true
formatter.isAdaptive = true
return formatter
}
func size(_ video: Video) -> Int? {
guard video.isLocal, let url = video.localStream?.localURL, let url = standardizedURL(url) else { return nil }
return size(url)
}
func size(_ url: URL) -> Int? {
try? url.resourceValues(forKeys: [.fileAllocatedSizeKey]).fileAllocatedSize
}
func formattedSize(_ video: Video) -> String? {
guard let size = size(video) else { return nil }
return sizeFormatter.string(fromByteCount: Int64(size))
}
func formattedSize(_ url: URL) -> String? {
guard let size = size(url) else { return nil }
return sizeFormatter.string(fromByteCount: Int64(size))
}
func removeDocument(_ url: URL) throws {
guard isDocument(url) else { return }
try fileManager.removeItem(at: url)
URLBookmarkModel.shared.removeBookmark(url)
refresh()
}
private func contents(of directory: URL) -> [URL] {
(try? fileManager.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: [.creationDateKey, .fileAllocatedSizeKey, .isDirectoryKey],
options: [.includesDirectoriesPostOrder, .skipsHiddenFiles]
)) ?? []
}
func displayLabelForDocument(_ file: URL) -> String {
let components = file.absoluteString.components(separatedBy: "/Documents/")
if components.count == 2 {
let component = components[1]
return component.isEmpty ? "Documents" : component.removingPercentEncoding ?? component
}
return "Document"
}
func standardizedURL(_ url: URL) -> URL? {
let standardizedURL = NSString(string: url.absoluteString).standardizingPath
return URL(string: standardizedURL)
}
func refresh() {
refreshID = UUID()
}
}

View File

@@ -3,19 +3,16 @@ import Foundation
struct FavoriteItem: Codable, Equatable, Identifiable, Defaults.Serializable {
enum Section: Codable, Equatable, Defaults.Serializable {
case history
case subscriptions
case popular
case trending(String, String?)
case channel(String, String, String)
case playlist(String, String)
case channelPlaylist(String, String, String)
case channel(String, String)
case playlist(String)
case channelPlaylist(String, String)
case searchQuery(String, String, String, String)
var label: String {
switch self {
case .history:
return "History"
case .subscriptions:
return "Subscriptions"
case .popular:
@@ -24,9 +21,9 @@ struct FavoriteItem: Codable, Equatable, Identifiable, Defaults.Serializable {
let trendingCountry = Country(rawValue: country)!
let trendingCategory = category.isNil ? nil : TrendingCategory(rawValue: category!)
return "\(trendingCountry.flag) \(trendingCountry.id) \(trendingCategory?.name ?? "Trending")"
case let .channel(_, _, name):
case let .channel(_, name):
return name
case let .channelPlaylist(_, _, name):
case let .channelPlaylist(_, name):
return name
case let .searchQuery(text, date, duration, order):
var label = "Search: \"\(text)\""
@@ -47,14 +44,10 @@ struct FavoriteItem: Codable, Equatable, Identifiable, Defaults.Serializable {
}
}
static func == (lhs: Self, rhs: Self) -> Bool {
static func == (lhs: FavoriteItem, rhs: FavoriteItem) -> Bool {
lhs.section == rhs.section
}
var id = UUID().uuidString
var section: Section
var widgetSettingsKey: String {
"favorites-\(id)"
}
}

View File

@@ -1,134 +0,0 @@
import Defaults
import Foundation
struct FavoritesModel {
static let shared = Self()
@Default(.showFavoritesInHome) var showFavoritesInHome
@Default(.favorites) var all
@Default(.widgetsSettings) var widgetsSettings
var isEnabled: Bool {
showFavoritesInHome
}
func contains(_ item: FavoriteItem) -> Bool {
all.contains { $0 == item }
}
func toggle(_ item: FavoriteItem) {
if contains(item) {
remove(item)
} else {
add(item)
}
}
func add(_ item: FavoriteItem) {
if contains(item) { return }
all.append(item)
}
func remove(_ item: FavoriteItem) {
if let index = all.firstIndex(where: { $0 == item }) {
all.remove(at: index)
}
}
func canMoveUp(_ item: FavoriteItem) -> Bool {
if let index = all.firstIndex(where: { $0 == item }) {
return index > all.startIndex
}
return false
}
func canMoveDown(_ item: FavoriteItem) -> Bool {
if let index = all.firstIndex(where: { $0 == item }) {
return index < all.endIndex - 1
}
return false
}
func moveUp(_ item: FavoriteItem) {
guard canMoveUp(item) else {
return
}
if let from = all.firstIndex(where: { $0 == item }) {
all.move(
fromOffsets: IndexSet(integer: from),
toOffset: from - 1
)
}
}
func moveDown(_ item: FavoriteItem) {
guard canMoveDown(item) else {
return
}
if let from = all.firstIndex(where: { $0 == item }) {
all.move(
fromOffsets: IndexSet(integer: from),
toOffset: from + 2
)
}
}
func addableItems() -> [FavoriteItem] {
let allItems = [
FavoriteItem(section: .subscriptions),
FavoriteItem(section: .popular),
FavoriteItem(section: .history)
]
return allItems.filter { item in !all.contains { $0.section == item.section } }
}
func listingStyle(_ item: FavoriteItem) -> WidgetListingStyle {
widgetSettings(item).listingStyle
}
func limit(_ item: FavoriteItem) -> Int {
min(WidgetSettings.maxLimit(listingStyle(item)), widgetSettings(item).limit)
}
func setListingStyle(_ style: WidgetListingStyle, _ item: FavoriteItem) {
if let index = widgetsSettings.firstIndex(where: { $0.id == item.widgetSettingsKey }) {
var settings = widgetsSettings[index]
settings.listingStyle = style
widgetsSettings[index] = settings
} else {
let settings = WidgetSettings(id: item.widgetSettingsKey, listingStyle: style)
widgetsSettings.append(settings)
}
}
func setLimit(_ limit: Int, _ item: FavoriteItem) {
if let index = widgetsSettings.firstIndex(where: { $0.id == item.widgetSettingsKey }) {
var settings = widgetsSettings[index]
let limit = min(max(1, limit), WidgetSettings.maxLimit(settings.listingStyle))
settings.limit = limit
widgetsSettings[index] = settings
} else {
var settings = WidgetSettings(id: item.widgetSettingsKey, limit: limit)
let limit = min(max(1, limit), WidgetSettings.maxLimit(settings.listingStyle))
settings.limit = limit
widgetsSettings.append(settings)
}
}
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)
}
}
}

View File

@@ -0,0 +1,82 @@
import Defaults
import Foundation
struct FavoritesModel {
static let shared = FavoritesModel()
@Default(.favorites) var all
@Default(.visibleSections) var visibleSections
var isEnabled: Bool {
visibleSections.contains(.favorites)
}
func contains(_ item: FavoriteItem) -> Bool {
all.contains { $0 == item }
}
func toggle(_ item: FavoriteItem) {
contains(item) ? remove(item) : add(item)
}
func add(_ item: FavoriteItem) {
all.append(item)
}
func remove(_ item: FavoriteItem) {
if let index = all.firstIndex(where: { $0 == item }) {
all.remove(at: index)
}
}
func canMoveUp(_ item: FavoriteItem) -> Bool {
if let index = all.firstIndex(where: { $0 == item }) {
return index > all.startIndex
}
return false
}
func canMoveDown(_ item: FavoriteItem) -> Bool {
if let index = all.firstIndex(where: { $0 == item }) {
return index < all.endIndex - 1
}
return false
}
func moveUp(_ item: FavoriteItem) {
guard canMoveUp(item) else {
return
}
if let from = all.firstIndex(where: { $0 == item }) {
all.move(
fromOffsets: IndexSet(integer: from),
toOffset: from - 1
)
}
}
func moveDown(_ item: FavoriteItem) {
guard canMoveDown(item) else {
return
}
if let from = all.firstIndex(where: { $0 == item }) {
all.move(
fromOffsets: IndexSet(integer: from),
toOffset: from + 2
)
}
}
func addableItems() -> [FavoriteItem] {
let allItems = [
FavoriteItem(section: .subscriptions),
FavoriteItem(section: .popular)
]
return allItems.filter { item in !all.contains { $0.section == item.section } }
}
}

View File

@@ -1,304 +0,0 @@
import Cache
import CoreData
import Defaults
import Foundation
import Siesta
import SwiftyJSON
final class FeedModel: ObservableObject, CacheModel {
static let shared = FeedModel()
@Published var isLoading = false
@Published var videos = [Video]()
@Published private var page = 1
@Published var watchedUUID = UUID()
private var feedCount = UnwatchedFeedCountModel.shared
private var cacheModel = FeedCacheModel.shared
private var accounts = AccountsModel.shared
var storage: Storage<String, JSON>?
@Published var error: RequestError?
private var backgroundContext = PersistenceController.shared.container.newBackgroundContext()
var feed: Resource? {
accounts.api.feed(page)
}
func loadResources(force: Bool = false, onCompletion: @escaping () -> Void = {}) {
DispatchQueue.global(qos: .background).async { [weak self] in
guard let self else { return }
if force || self.videos.isEmpty {
self.loadCachedFeed()
}
if self.accounts.app == .invidious {
// Invidious for some reason won't refresh feed until homepage is loaded
DispatchQueue.main.async { [weak self] in
guard let self, let home = self.accounts.api.home else { return }
self.request(home, force: force)?
.onCompletion { _ in
self.loadFeed(force: force, onCompletion: onCompletion)
}
}
} else {
self.loadFeed(force: force, onCompletion: onCompletion)
}
}
}
func loadFeed(force: Bool = false, paginating: Bool = false, onCompletion: @escaping () -> Void = {}) {
DispatchQueue.main.async { [weak self] in
guard let self,
!self.isLoading,
let account = self.accounts.current
else {
self?.isLoading = false
onCompletion()
return
}
if paginating {
self.page += 1
} else {
self.page = 1
}
let feedBeforeLoad = self.feed
var request: Request?
if let feedBeforeLoad {
request = self.request(feedBeforeLoad, force: force)
}
if request != nil {
self.isLoading = true
}
request?
.onCompletion { _ in
self.isLoading = false
onCompletion()
}
.onSuccess { response in
self.error = nil
if let videos: [Video] = response.typedContent() {
if paginating {
self.videos.append(contentsOf: videos)
} else {
self.videos = videos
self.cacheModel.storeFeed(account: account, videos: self.videos)
self.calculateUnwatchedFeed()
}
}
}
.onFailure { self.error = $0 }
}
}
func reset() {
videos.removeAll()
page = 1
}
func loadNextPage() {
guard accounts.app.paginatesSubscriptions, !isLoading else { return }
loadFeed(force: true, paginating: true)
}
func onAccountChange() {
reset()
error = nil
loadResources(force: true)
calculateUnwatchedFeed()
}
func calculateUnwatchedFeed() {
guard let account = accounts.current, accounts.signedIn else { return }
let feed = cacheModel.retrieveFeed(account: account)
backgroundContext.perform { [weak self] in
guard let self else { return }
let watched = self.watchFetchRequestResult(feed, context: self.backgroundContext).filter(\.finished)
let unwatched = feed.filter { video in !watched.contains { $0.videoID == video.videoID } }
let unwatchedCount = max(0, feed.count - watched.count)
DispatchQueue.main.async { [weak self] in
guard let self else { return }
if unwatchedCount != self.feedCount.unwatched[account] {
self.feedCount.unwatched[account] = unwatchedCount
}
let byChannel = Dictionary(grouping: unwatched) { $0.channel.id }.mapValues(\.count)
self.feedCount.unwatchedByChannel[account] = byChannel
self.watchedUUID = UUID()
}
}
}
func markAllFeedAsWatched() {
let mark = { [weak self] in
guard let self else { return }
self.markVideos(self.videos, watched: true, watchedAt: Date(timeIntervalSince1970: 0))
}
if videos.isEmpty {
loadCachedFeed { mark() }
} else {
mark()
}
}
var canMarkAllFeedAsWatched: Bool {
guard let account = accounts.current, accounts.signedIn else { return false }
return (feedCount.unwatched[account] ?? 0) > 0
}
func canMarkChannelAsWatched(_ channelID: Channel.ID) -> Bool {
guard let account = accounts.current, accounts.signedIn else { return false }
return feedCount.unwatchedByChannel[account]?.keys.contains(channelID) ?? false
}
func markChannelAsWatched(_ channelID: Channel.ID) {
guard accounts.signedIn else { return }
let mark = { [weak self] in
guard let self else { return }
self.markVideos(self.videos.filter { $0.channel.id == channelID }, watched: true)
}
if videos.isEmpty {
loadCachedFeed { mark() }
} else {
mark()
}
}
func markChannelAsUnwatched(_ channelID: Channel.ID) {
guard accounts.signedIn else { return }
let mark = { [weak self] in
guard let self else { return }
self.markVideos(self.videos.filter { $0.channel.id == channelID }, watched: false)
}
if videos.isEmpty {
loadCachedFeed { mark() }
} else {
mark()
}
}
func markAllFeedAsUnwatched() {
guard accounts.current != nil else { return }
let mark = { [weak self] in
guard let self else { return }
self.markVideos(self.videos, watched: false)
}
if videos.isEmpty {
loadCachedFeed { mark() }
} else {
mark()
}
}
func markVideos(_ videos: [Video], watched: Bool, watchedAt: Date? = nil) {
guard accounts.signedIn, let account = accounts.current else { return }
backgroundContext.perform { [weak self] in
guard let self else { return }
if watched {
videos.forEach { Watch.markAsWatched(videoID: $0.videoID, account: account, duration: $0.length, watchedAt: watchedAt, context: self.backgroundContext) }
} else {
let watches = self.watchFetchRequestResult(videos, context: self.backgroundContext)
watches.forEach { self.backgroundContext.delete($0) }
}
try? self.backgroundContext.save()
self.calculateUnwatchedFeed()
WatchModel.shared.watchesChanged()
}
}
func playUnwatchedFeed() {
guard let account = accounts.current, accounts.signedIn else { return }
let videos = cacheModel.retrieveFeed(account: account)
guard !videos.isEmpty else { return }
let watches = watchFetchRequestResult(videos, context: backgroundContext)
let watchesIDs = watches.map(\.videoID)
let unwatched = videos.filter { video in
if FeatureFlags.hideShortsEnabled, Defaults[.hideShorts], video.short {
return false
}
if !watchesIDs.contains(video.videoID) {
return true
}
if let watch = watches.first(where: { $0.videoID == video.videoID }),
watch.finished
{
return false
}
return true
}
guard !unwatched.isEmpty else { return }
PlayerModel.shared.play(unwatched)
}
var canPlayUnwatchedFeed: Bool {
guard let account = accounts.current, accounts.signedIn else { return false }
return (feedCount.unwatched[account] ?? 0) > 0
}
var watchedId: String {
watchedUUID.uuidString
}
var feedTime: Date? {
if let account = accounts.current {
return cacheModel.getFeedTime(account: account)
}
return nil
}
var formattedFeedTime: String {
getFormattedDate(feedTime)
}
private func loadCachedFeed(_ onCompletion: @escaping () -> Void = {}) {
guard let account = accounts.current, accounts.signedIn else { return }
let cache = cacheModel.retrieveFeed(account: account)
if !cache.isEmpty {
DispatchQueue.main.async(qos: .userInteractive) { [weak self] in
self?.videos = cache
onCompletion()
}
}
}
private func request(_ resource: Resource, force: Bool = false) -> Request? {
if force {
return resource.load()
}
return resource.loadIfNeeded()
}
private func watchFetchRequestResult(_ videos: [Video], context: NSManagedObjectContext) -> [Watch] {
let watchFetchRequest = Watch.fetchRequest()
watchFetchRequest.predicate = NSPredicate(format: "videoID IN %@", videos.map(\.videoID) as [String])
return (try? context.fetch(watchFetchRequest)) ?? []
}
}

View File

@@ -2,127 +2,80 @@ import CoreData
import CoreMedia
import Defaults
import Foundation
import Siesta
import SwiftyJSON
extension PlayerModel {
func historyVideo(_ id: String) -> Video? {
historyVideos.first { $0.videoID == id }
}
func loadHistoryVideoDetails(_ watch: Watch, onCompletion: @escaping () -> Void = {}) {
guard historyVideo(watch.videoID).isNil else {
onCompletion()
func loadHistoryVideoDetails(_ id: Video.ID) {
guard historyVideo(id).isNil else {
return
}
if !Video.VideoID.isValid(watch.videoID), let url = URL(string: watch.videoID) {
historyVideos.append(.local(url))
onCompletion()
return
}
if let video = VideosCacheModel.shared.retrieveVideo(watch.video.cacheKey) {
historyVideos.append(video)
onCompletion()
return
}
guard let api = playerAPI(watch.video) else { return }
api.video(watch.videoID)
.load()
.onSuccess { [weak self] response in
guard let self else { return }
if let video: Video = response.typedContent() {
VideosCacheModel.shared.storeVideo(video)
self.historyVideos.append(video)
onCompletion()
}
}
.onCompletion { _ in
self.logger.info("LOADED history details: \(watch.videoID)")
}
}
func updateWatch(finished: Bool = false, time: CMTime? = nil) {
guard let currentVideo, saveHistory, isPlaying else { return }
let id = currentVideo.videoID
let time = time ?? backend.currentTime
let seconds = time?.seconds ?? 0
if seconds < 3 {
return
}
let watchFetchRequest = Watch.fetchRequest()
watchFetchRequest.predicate = NSPredicate(format: "videoID = %@", id as String)
let results = try? backgroundContext.fetch(watchFetchRequest)
backgroundContext.perform { [weak self] in
guard let self, finished || time != nil || self.backend.isPlaying else {
accounts.api.video(id).load().onSuccess { [weak self] response in
guard let video: Video = response.typedContent() else {
return
}
let watch: Watch!
let duration = self.activeBackend == .mpv ? self.playerTime.duration.seconds : self.avPlayerBackend.playerItemDuration?.seconds ?? 0
if results?.isEmpty ?? true {
watch = Watch(context: self.backgroundContext)
watch.videoID = id
watch.appName = currentVideo.app.rawValue
watch.instanceURL = currentVideo.instanceURL
} else {
watch = results?.first
}
if duration.isFinite, duration > 0 {
watch.videoDuration = duration
}
if watch.finished {
if !finished, self.resetWatchedStatusOnPlaying, seconds.isFinite, seconds > 0 {
watch.stoppedAt = seconds
}
} else if seconds.isFinite, seconds > 0 {
watch.stoppedAt = seconds
}
watch.watchedAt = Date()
try? self.backgroundContext.save()
self?.historyVideos.append(video)
}
}
func removeHistory() {
removeAllWatches()
BookmarksCacheModel.shared.clear()
func updateWatch(finished: Bool = false) {
guard let id = currentVideo?.videoID,
Defaults[.saveHistory]
else {
return
}
let time = backend.currentTime
let seconds = time?.seconds ?? 0
let watch: Watch!
let watchFetchRequest = Watch.fetchRequest()
watchFetchRequest.predicate = NSPredicate(format: "videoID = %@", id as String)
let results = try? context.fetch(watchFetchRequest)
if results?.isEmpty ?? true {
if seconds < 1 {
return
}
watch = Watch(context: context)
watch.videoID = id
} else {
watch = results?.first
if !Defaults[.resetWatchedStatusOnPlaying], watch.finished {
return
}
}
if let seconds = playerItemDuration?.seconds {
watch.videoDuration = seconds
}
if finished {
watch.stoppedAt = watch.videoDuration
} else if seconds.isFinite, seconds > 0 {
watch.stoppedAt = seconds
}
watch.watchedAt = Date()
try? context.save()
}
func removeWatch(_ watch: Watch) {
context.perform { [weak self] in
guard let self else { return }
self.context.delete(watch)
try? self.context.save()
FeedModel.shared.calculateUnwatchedFeed()
WatchModel.shared.watchesChanged()
}
context.delete(watch)
try? context.save()
}
func removeAllWatches() {
let watchesFetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "Watch")
let deleteRequest = NSBatchDeleteRequest(fetchRequest: watchesFetchRequest)
do {
try context.executeAndMergeChanges(deleteRequest)
try context.save()
} catch let error as NSError {
logger.info(.init(stringLiteral: error.localizedDescription))
}
_ = try? context.execute(deleteRequest)
_ = try? context.save()
}
}

View File

@@ -1,23 +0,0 @@
import Defaults
import SwiftyJSON
final class AdvancedSettingsGroupExporter: SettingsGroupExporter {
override var globalJSON: JSON {
[
"showPlayNowInBackendContextMenu": Defaults[.showPlayNowInBackendContextMenu],
"videoLoadingRetryCount": Defaults[.videoLoadingRetryCount],
"showMPVPlaybackStats": Defaults[.showMPVPlaybackStats],
"mpvEnableLogging": Defaults[.mpvEnableLogging],
"mpvCacheSecs": Defaults[.mpvCacheSecs],
"mpvCachePauseWait": Defaults[.mpvCachePauseWait],
"mpvCachePauseInital": Defaults[.mpvCachePauseInital],
"mpvDeinterlace": Defaults[.mpvDeinterlace],
"mpvHWdec": Defaults[.mpvHWdec],
"mpvDemuxerLavfProbeInfo": Defaults[.mpvDemuxerLavfProbeInfo],
"mpvSetRefreshToContentFPS": Defaults[.mpvSetRefreshToContentFPS],
"mpvInitialAudioSync": Defaults[.mpvInitialAudioSync],
"showCacheStatus": Defaults[.showCacheStatus],
"feedCacheSize": Defaults[.feedCacheSize]
]
}
}

View File

@@ -1,55 +0,0 @@
import Defaults
import SwiftyJSON
final class BrowsingSettingsGroupExporter: SettingsGroupExporter {
override var globalJSON: JSON {
[
"showHome": Defaults[.showHome],
"showOpenActionsInHome": Defaults[.showOpenActionsInHome],
"showQueueInHome": Defaults[.showQueueInHome],
"showFavoritesInHome": Defaults[.showFavoritesInHome],
"favorites": Defaults[.favorites].compactMap { jsonFromString(FavoriteItem.bridge.serialize($0)) },
"widgetsSettings": Defaults[.widgetsSettings].compactMap { widgetSettingsJSON($0) },
"startupSection": Defaults[.startupSection].rawValue,
"showSearchSuggestions": Defaults[.showSearchSuggestions],
"visibleSections": Defaults[.visibleSections].compactMap(\.rawValue),
"showOpenActionsToolbarItem": Defaults[.showOpenActionsToolbarItem],
"accountPickerDisplaysAnonymousAccounts": Defaults[.accountPickerDisplaysAnonymousAccounts],
"showUnwatchedFeedBadges": Defaults[.showUnwatchedFeedBadges],
"expandChannelDescription": Defaults[.expandChannelDescription],
"keepChannelsWithUnwatchedFeedOnTop": Defaults[.keepChannelsWithUnwatchedFeedOnTop],
"showChannelAvatarInChannelsLists": Defaults[.showChannelAvatarInChannelsLists],
"showChannelAvatarInVideosListing": Defaults[.showChannelAvatarInVideosListing],
"playerButtonSingleTapGesture": Defaults[.playerButtonSingleTapGesture].rawValue,
"playerButtonDoubleTapGesture": Defaults[.playerButtonDoubleTapGesture].rawValue,
"playerButtonShowsControlButtonsWhenMinimized": Defaults[.playerButtonShowsControlButtonsWhenMinimized],
"playerButtonIsExpanded": Defaults[.playerButtonIsExpanded],
"playerBarMaxWidth": Defaults[.playerBarMaxWidth],
"channelOnThumbnail": Defaults[.channelOnThumbnail],
"timeOnThumbnail": Defaults[.timeOnThumbnail],
"roundedThumbnails": Defaults[.roundedThumbnails],
"thumbnailsQuality": Defaults[.thumbnailsQuality].rawValue
]
}
override var platformJSON: JSON {
var export = JSON()
#if os(iOS)
export["showDocuments"].bool = Defaults[.showDocuments]
export["lockPortraitWhenBrowsing"].bool = Defaults[.lockPortraitWhenBrowsing]
#endif
#if !os(tvOS)
export["accountPickerDisplaysUsername"].bool = Defaults[.accountPickerDisplaysUsername]
#endif
return export
}
private func widgetSettingsJSON(_ settings: WidgetSettings) -> JSON {
var json = JSON()
json.dictionaryObject = WidgetSettingsBridge().serialize(settings)
return json
}
}

View File

@@ -1,42 +0,0 @@
import Defaults
import SwiftyJSON
final class ConstrolsSettingsGroupExporter: SettingsGroupExporter {
override var globalJSON: JSON {
[
"avPlayerUsesSystemControls": Defaults[.avPlayerUsesSystemControls],
"fullscreenPlayerGestureEnabled": Defaults[.fullscreenPlayerGestureEnabled],
"horizontalPlayerGestureEnabled": Defaults[.horizontalPlayerGestureEnabled],
"seekGestureSensitivity": Defaults[.seekGestureSensitivity],
"seekGestureSpeed": Defaults[.seekGestureSpeed],
"playerControlsLayout": Defaults[.playerControlsLayout].rawValue,
"fullScreenPlayerControlsLayout": Defaults[.fullScreenPlayerControlsLayout].rawValue,
"playerControlsBackgroundOpacity": Defaults[.playerControlsBackgroundOpacity],
"systemControlsCommands": Defaults[.systemControlsCommands].rawValue,
"buttonBackwardSeekDuration": Defaults[.buttonBackwardSeekDuration],
"buttonForwardSeekDuration": Defaults[.buttonForwardSeekDuration],
"gestureBackwardSeekDuration": Defaults[.gestureBackwardSeekDuration],
"gestureForwardSeekDuration": Defaults[.gestureForwardSeekDuration],
"systemControlsSeekDuration": Defaults[.systemControlsSeekDuration],
"playerControlsSettingsEnabled": Defaults[.playerControlsSettingsEnabled],
"playerControlsCloseEnabled": Defaults[.playerControlsCloseEnabled],
"playerControlsRestartEnabled": Defaults[.playerControlsRestartEnabled],
"playerControlsAdvanceToNextEnabled": Defaults[.playerControlsAdvanceToNextEnabled],
"playerControlsPlaybackModeEnabled": Defaults[.playerControlsPlaybackModeEnabled],
"playerControlsMusicModeEnabled": Defaults[.playerControlsMusicModeEnabled],
"playerActionsButtonLabelStyle": Defaults[.playerActionsButtonLabelStyle].rawValue,
"actionButtonShareEnabled": Defaults[.actionButtonShareEnabled],
"actionButtonAddToPlaylistEnabled": Defaults[.actionButtonAddToPlaylistEnabled],
"actionButtonSubscribeEnabled": Defaults[.actionButtonSubscribeEnabled],
"actionButtonSettingsEnabled": Defaults[.actionButtonSettingsEnabled],
"actionButtonHideEnabled": Defaults[.actionButtonHideEnabled],
"actionButtonCloseEnabled": Defaults[.actionButtonCloseEnabled],
"actionButtonFullScreenEnabled": Defaults[.actionButtonFullScreenEnabled],
"actionButtonPipEnabled": Defaults[.actionButtonPipEnabled],
"actionButtonLockOrientationEnabled": Defaults[.actionButtonLockOrientationEnabled],
"actionButtonRestartEnabled": Defaults[.actionButtonRestartEnabled],
"actionButtonAdvanceToNextItemEnabled": Defaults[.actionButtonAdvanceToNextItemEnabled],
"actionButtonMusicModeEnabled": Defaults[.actionButtonMusicModeEnabled]
]
}
}

View File

@@ -1,24 +0,0 @@
import Defaults
import SwiftyJSON
final class HistorySettingsGroupExporter: SettingsGroupExporter {
override var globalJSON: JSON {
[
"saveRecents": Defaults[.saveRecents],
"saveHistory": Defaults[.saveHistory],
"showRecents": Defaults[.showRecents],
"limitRecents": Defaults[.limitRecents],
"limitRecentsAmount": Defaults[.limitRecentsAmount],
"showWatchingProgress": Defaults[.showWatchingProgress],
"saveLastPlayed": Defaults[.saveLastPlayed],
"watchedVideoPlayNowBehavior": Defaults[.watchedVideoPlayNowBehavior].rawValue,
"watchedThreshold": Defaults[.watchedThreshold],
"resetWatchedStatusOnPlaying": Defaults[.resetWatchedStatusOnPlaying],
"watchedVideoStyle": Defaults[.watchedVideoStyle].rawValue,
"watchedVideoBadgeColor": Defaults[.watchedVideoBadgeColor].rawValue,
"showToggleWatchedStatusButton": Defaults[.showToggleWatchedStatusButton]
]
}
}

View File

@@ -1,56 +0,0 @@
import Defaults
import SwiftyJSON
final class LocationsSettingsGroupExporter: SettingsGroupExporter {
var includePublicInstances = true
var includeInstances = true
var includeAccounts = true
var includeAccountsUnencryptedPasswords = false
init(includePublicInstances: Bool = true, includeInstances: Bool = true, includeAccounts: Bool = true, includeAccountsUnencryptedPasswords: Bool = false) {
self.includePublicInstances = includePublicInstances
self.includeInstances = includeInstances
self.includeAccounts = includeAccounts
self.includeAccountsUnencryptedPasswords = includeAccountsUnencryptedPasswords
}
override var globalJSON: JSON {
var json = JSON()
if includePublicInstances {
json["instancesManifest"].string = Defaults[.instancesManifest]
json["countryOfPublicInstances"].string = Defaults[.countryOfPublicInstances] ?? ""
}
if includeInstances {
json["instances"].arrayObject = Defaults[.instances].compactMap { instanceJSON($0) }
}
if includeAccounts {
json["accounts"].arrayObject = Defaults[.accounts].compactMap { account in
var account = account
let (username, password) = AccountsModel.getCredentials(account)
account.username = username ?? ""
if includeAccountsUnencryptedPasswords {
account.password = password ?? ""
}
return accountJSON(account).dictionaryObject
}
}
return json
}
private func instanceJSON(_ instance: Instance) -> JSON {
var json = JSON()
json.dictionaryObject = InstancesBridge().serialize(instance)
return json
}
private func accountJSON(_ account: Account) -> JSON {
var json = JSON()
json.dictionaryObject = AccountsBridge().serialize(account)
return json
}
}

View File

@@ -1,27 +0,0 @@
import Defaults
import SwiftyJSON
final class OtherDataSettingsGroupExporter: SettingsGroupExporter {
override var globalJSON: JSON {
[
"lastAccountID": Defaults[.lastAccountID] ?? "",
"lastInstanceID": Defaults[.lastInstanceID] ?? "",
"playerRate": Defaults[.playerRate],
"trendingCategory": Defaults[.trendingCategory].rawValue,
"trendingCountry": Defaults[.trendingCountry].rawValue,
"subscriptionsViewPage": Defaults[.subscriptionsViewPage].rawValue,
"subscriptionsListingStyle": Defaults[.subscriptionsListingStyle].rawValue,
"popularListingStyle": Defaults[.popularListingStyle].rawValue,
"trendingListingStyle": Defaults[.trendingListingStyle].rawValue,
"playlistListingStyle": Defaults[.playlistListingStyle].rawValue,
"channelPlaylistListingStyle": Defaults[.channelPlaylistListingStyle].rawValue,
"searchListingStyle": Defaults[.searchListingStyle].rawValue,
"hideShorts": Defaults[.hideShorts],
"hideWatched": Defaults[.hideWatched]
]
}
}

View File

@@ -1,54 +0,0 @@
import Defaults
import SwiftyJSON
final class PlayerSettingsGroupExporter: SettingsGroupExporter {
override var globalJSON: JSON {
[
"playerInstanceID": Defaults[.playerInstanceID] ?? "",
"pauseOnHidingPlayer": Defaults[.pauseOnHidingPlayer],
"closeVideoOnEOF": Defaults[.closeVideoOnEOF],
"exitFullscreenOnEOF": Defaults[.exitFullscreenOnEOF],
"expandVideoDescription": Defaults[.expandVideoDescription],
"collapsedLinesDescription": Defaults[.collapsedLinesDescription],
"showChapters": Defaults[.showChapters],
"showChapterThumbnails": Defaults[.showChapterThumbnails],
"showChapterThumbnailsOnlyWhenDifferent": Defaults[.showChapterThumbnailsOnlyWhenDifferent],
"expandChapters": Defaults[.expandChapters],
"showRelated": Defaults[.showRelated],
"showInspector": Defaults[.showInspector].rawValue,
"playerSidebar": Defaults[.playerSidebar].rawValue,
"showKeywords": Defaults[.showKeywords],
"enableReturnYouTubeDislike": Defaults[.enableReturnYouTubeDislike],
"closePiPOnNavigation": Defaults[.closePiPOnNavigation],
"closePiPOnOpeningPlayer": Defaults[.closePiPOnOpeningPlayer],
"closePlayerOnOpeningPiP": Defaults[.closePlayerOnOpeningPiP],
"captionsAutoShow": Defaults[.captionsAutoShow],
"captionsDefaultLanguageCode": Defaults[.captionsDefaultLanguageCode],
"captionsFallbackLanguageCode": Defaults[.captionsFallbackLanguageCode],
"captionsFontScaleSize": Defaults[.captionsFontScaleSize],
"captionsFontColor": Defaults[.captionsFontColor]
]
}
override var platformJSON: JSON {
var export = JSON()
#if !os(macOS)
export["pauseOnEnteringBackground"].bool = Defaults[.pauseOnEnteringBackground]
#endif
export["showComments"].bool = Defaults[.showComments]
#if !os(tvOS)
export["showScrollToTopInComments"].bool = Defaults[.showScrollToTopInComments]
#endif
#if os(iOS)
export["isOrientationLocked"].bool = Defaults[.isOrientationLocked]
export["enterFullscreenInLandscape"].bool = Defaults[.enterFullscreenInLandscape]
export["rotateToLandscapeOnEnterFullScreen"].string = Defaults[.rotateToLandscapeOnEnterFullScreen].rawValue
#endif
return export
}
}

View File

@@ -1,21 +0,0 @@
import Defaults
import SwiftyJSON
final class QualitySettingsGroupExporter: SettingsGroupExporter {
override var globalJSON: JSON {
[
"batteryCellularProfile": Defaults[.batteryCellularProfile],
"batteryNonCellularProfile": Defaults[.batteryNonCellularProfile],
"chargingCellularProfile": Defaults[.chargingCellularProfile],
"chargingNonCellularProfile": Defaults[.chargingNonCellularProfile],
"forceAVPlayerForLiveStreams": Defaults[.forceAVPlayerForLiveStreams],
"qualityProfiles": Defaults[.qualityProfiles].compactMap { qualityProfileJSON($0) }
]
}
func qualityProfileJSON(_ profile: QualityProfile) -> JSON {
var json = JSON()
json.dictionaryObject = QualityProfileBridge().serialize(profile)
return json
}
}

View File

@@ -1,16 +0,0 @@
import Defaults
import SwiftyJSON
final class RecentlyOpenedExporter: SettingsGroupExporter {
override var globalJSON: JSON {
[
"recentlyOpened": Defaults[.recentlyOpened].compactMap { recentItemJSON($0) }
]
}
private func recentItemJSON(_ recentItem: RecentItem) -> JSON {
var json = JSON()
json.dictionaryObject = RecentItemBridge().serialize(recentItem)
return json
}
}

View File

@@ -1,32 +0,0 @@
import Foundation
import SwiftyJSON
class SettingsGroupExporter { // swiftlint:disable:this final_class
var globalJSON: JSON {
[]
}
var platformJSON: JSON {
[]
}
var exportJSON: JSON {
var json = globalJSON
if !platformJSON.isEmpty {
try? json.merge(with: platformJSON)
}
return json
}
func jsonFromString(_ string: String?) -> JSON? {
if let data = string?.data(using: .utf8, allowLossyConversion: false),
let json = try? JSON(data: data)
{
return json
}
return nil
}
}

View File

@@ -1,15 +0,0 @@
import Defaults
import SwiftyJSON
final class SponsorBlockSettingsGroupExporter: SettingsGroupExporter {
override var globalJSON: JSON {
[
"sponsorBlockInstance": Defaults[.sponsorBlockInstance],
"sponsorBlockCategories": Array(Defaults[.sponsorBlockCategories]),
"sponsorBlockColors": Defaults[.sponsorBlockColors],
"sponsorBlockShowTimeWithSkipsRemoved": Defaults[.sponsorBlockShowTimeWithSkipsRemoved],
"sponsorBlockShowCategoriesInTimeline": Defaults[.sponsorBlockShowCategoriesInTimeline],
"sponsorBlockShowNoticeAfterSkip": Defaults[.sponsorBlockShowNoticeAfterSkip]
]
}
}

View File

@@ -1,193 +0,0 @@
import Defaults
import Foundation
import SwiftUI
import SwiftyJSON
final class ImportExportSettingsModel: ObservableObject {
static let shared = ImportExportSettingsModel()
static var exportFile: URL {
YatteeApp.settingsExportDirectory
.appendingPathComponent("Yattee Settings from \(Constants.deviceName).\(settingsExtension)")
}
static var settingsExtension: String {
"yatteesettings"
}
enum ExportGroup: String, Identifiable, CaseIterable {
case browsingSettings
case playerSettings
case controlsSettings
case qualitySettings
case historySettings
case sponsorBlockSettings
case advancedSettings
case locationsSettings
case instances
case accounts
case accountsUnencryptedPasswords
case recentlyOpened
case otherData
static var settingsGroups: [Self] {
[.browsingSettings, .playerSettings, .controlsSettings, .qualitySettings, .historySettings, .sponsorBlockSettings, .advancedSettings]
}
static var locationsGroups: [Self] {
[.locationsSettings, .instances, .accounts, .accountsUnencryptedPasswords]
}
static var otherGroups: [Self] {
[.recentlyOpened, .otherData]
}
var id: RawValue {
rawValue
}
var label: String {
switch self {
case .browsingSettings:
return "Browsing"
case .playerSettings:
return "Player"
case .controlsSettings:
return "Controls"
case .qualitySettings:
return "Quality"
case .historySettings:
return "History"
case .sponsorBlockSettings:
return "SponsorBlock"
case .locationsSettings:
return "Public Locations"
case .instances:
return "Custom Locations"
case .accounts:
return "Accounts"
case .accountsUnencryptedPasswords:
return "Accounts passwords (unencrypted)"
case .advancedSettings:
return "Advanced"
case .recentlyOpened:
return "Recents"
case .otherData:
return "Other data"
}
}
}
@Published var selectedExportGroups = Set<ExportGroup>()
static var defaultExportGroups = Set<ExportGroup>([
.browsingSettings,
.playerSettings,
.controlsSettings,
.qualitySettings,
.historySettings,
.sponsorBlockSettings,
.locationsSettings,
.instances,
.accounts,
.advancedSettings
])
@Published var isExportInProgress = false
private var navigation = NavigationModel.shared
private var settings = SettingsModel.shared
func toggleExportGroupSelection(_ group: ExportGroup) {
if isGroupSelected(group) {
selectedExportGroups.remove(group)
} else {
selectedExportGroups.insert(group)
}
removeNotEnabledSelectedGroups()
}
func reset() {
isExportInProgress = false
selectedExportGroups = Self.defaultExportGroups
}
func reset(_ model: ImportSettingsFileModel? = nil) {
reset()
guard let model else { return }
selectedExportGroups = selectedExportGroups.filter { model.isGroupIncludedInFile($0) }
}
func exportAction() {
DispatchQueue.global(qos: .background).async { [weak self] in
var writingOptions: JSONSerialization.WritingOptions = []
#if DEBUG
writingOptions.insert(.prettyPrinted)
writingOptions.insert(.sortedKeys)
#endif
try? self?.jsonForExport?.rawString(options: writingOptions)?.write(to: Self.exportFile, atomically: true, encoding: String.Encoding.utf8)
#if os(macOS)
DispatchQueue.main.async { [weak self] in
self?.isExportInProgress = false
}
NSWorkspace.shared.selectFile(Self.exportFile.path, inFileViewerRootedAtPath: YatteeApp.settingsExportDirectory.path)
#endif
}
}
private var jsonForExport: JSON? {
[
"metadata": metadataJSON,
"browsingSettings": selectedExportGroups.contains(.browsingSettings) ? BrowsingSettingsGroupExporter().exportJSON : JSON(),
"playerSettings": selectedExportGroups.contains(.playerSettings) ? PlayerSettingsGroupExporter().exportJSON : JSON(),
"controlsSettings": selectedExportGroups.contains(.controlsSettings) ? ConstrolsSettingsGroupExporter().exportJSON : JSON(),
"qualitySettings": selectedExportGroups.contains(.qualitySettings) ? QualitySettingsGroupExporter().exportJSON : JSON(),
"historySettings": selectedExportGroups.contains(.historySettings) ? HistorySettingsGroupExporter().exportJSON : JSON(),
"sponsorBlockSettings": selectedExportGroups.contains(.sponsorBlockSettings) ? SponsorBlockSettingsGroupExporter().exportJSON : JSON(),
"locationsSettings": LocationsSettingsGroupExporter(
includePublicInstances: isGroupSelected(.locationsSettings),
includeInstances: isGroupSelected(.instances),
includeAccounts: isGroupSelected(.accounts),
includeAccountsUnencryptedPasswords: isGroupSelected(.accountsUnencryptedPasswords)
).exportJSON,
"advancedSettings": selectedExportGroups.contains(.advancedSettings) ? AdvancedSettingsGroupExporter().exportJSON : JSON(),
"recentlyOpened": selectedExportGroups.contains(.recentlyOpened) ? RecentlyOpenedExporter().exportJSON : JSON(),
"otherData": selectedExportGroups.contains(.otherData) ? OtherDataSettingsGroupExporter().exportJSON : JSON()
]
}
private var metadataJSON: JSON {
[
"build": YatteeApp.build,
"timestamp": "\(Date().timeIntervalSince1970)",
"platform": Constants.platform
]
}
func isGroupSelected(_ group: ExportGroup) -> Bool {
selectedExportGroups.contains(group)
}
func isGroupEnabled(_ group: ExportGroup) -> Bool {
switch group {
case .accounts:
return selectedExportGroups.contains(.instances)
case .accountsUnencryptedPasswords:
return selectedExportGroups.contains(.instances) && selectedExportGroups.contains(.accounts)
default:
return true
}
}
func removeNotEnabledSelectedGroups() {
selectedExportGroups = selectedExportGroups.filter { isGroupEnabled($0) }
}
var isExportAvailable: Bool {
!selectedExportGroups.isEmpty && !isExportInProgress
}
}

View File

@@ -1,150 +0,0 @@
import Defaults
import Foundation
import SwiftyJSON
final class ImportSettingsFileModel: ObservableObject {
static let shared = ImportSettingsFileModel()
var locationsSettingsGroupImporter: LocationsSettingsGroupImporter? {
if let locationsSettings = json.dictionaryValue["locationsSettings"] {
return LocationsSettingsGroupImporter(
json: locationsSettings,
includePublicLocations: importExportModel.isGroupEnabled(.locationsSettings),
includedInstancesIDs: sheetViewModel.selectedInstances,
includedAccountsIDs: sheetViewModel.selectedAccounts,
includedAccountsPasswords: sheetViewModel.importableAccountsPasswords
)
}
return nil
}
var importExportModel = ImportExportSettingsModel.shared
var sheetViewModel = ImportSettingsSheetViewModel.shared
var loadTask: URLSessionTask?
func isGroupIncludedInFile(_ group: ImportExportSettingsModel.ExportGroup) -> Bool {
switch group {
case .locationsSettings:
return isPublicInstancesSettingsGroupInFile || instancesOrAccountsInFile
default:
return !groupJSON(group).isEmpty
}
}
var isPublicInstancesSettingsGroupInFile: Bool {
guard let dict = groupJSON(.locationsSettings).dictionary else { return false }
return dict.keys.contains("instancesManifest") || dict.keys.contains("countryOfPublicInstances")
}
var instancesOrAccountsInFile: Bool {
guard let dict = groupJSON(.locationsSettings).dictionary else { return false }
return (dict.keys.contains("instances") && !(dict["instances"]?.arrayValue.isEmpty ?? true)) ||
(dict.keys.contains("accounts") && !(dict["accounts"]?.arrayValue.isEmpty ?? true))
}
func groupJSON(_ group: ImportExportSettingsModel.ExportGroup) -> JSON {
json.dictionaryValue[group.rawValue] ?? .init()
}
func performImport() {
if importExportModel.isGroupSelected(.browsingSettings), isGroupIncludedInFile(.browsingSettings) {
BrowsingSettingsGroupImporter(json: groupJSON(.browsingSettings)).performImport()
}
if importExportModel.isGroupSelected(.playerSettings), isGroupIncludedInFile(.playerSettings) {
PlayerSettingsGroupImporter(json: groupJSON(.playerSettings)).performImport()
}
if importExportModel.isGroupSelected(.controlsSettings), isGroupIncludedInFile(.controlsSettings) {
ConstrolsSettingsGroupImporter(json: groupJSON(.controlsSettings)).performImport()
}
if importExportModel.isGroupSelected(.qualitySettings), isGroupIncludedInFile(.qualitySettings) {
QualitySettingsGroupImporter(json: groupJSON(.qualitySettings)).performImport()
}
if importExportModel.isGroupSelected(.historySettings), isGroupIncludedInFile(.historySettings) {
HistorySettingsGroupImporter(json: groupJSON(.historySettings)).performImport()
}
if importExportModel.isGroupSelected(.sponsorBlockSettings), isGroupIncludedInFile(.sponsorBlockSettings) {
SponsorBlockSettingsGroupImporter(json: groupJSON(.sponsorBlockSettings)).performImport()
}
locationsSettingsGroupImporter?.performImport()
if importExportModel.isGroupSelected(.advancedSettings), isGroupIncludedInFile(.advancedSettings) {
AdvancedSettingsGroupImporter(json: groupJSON(.advancedSettings)).performImport()
}
if importExportModel.isGroupSelected(.recentlyOpened), isGroupIncludedInFile(.recentlyOpened) {
RecentlyOpenedImporter(json: groupJSON(.recentlyOpened)).performImport()
}
if importExportModel.isGroupSelected(.otherData), isGroupIncludedInFile(.otherData) {
OtherDataSettingsGroupImporter(json: groupJSON(.otherData)).performImport()
}
}
@Published var json = JSON()
func loadData(_ url: URL) {
json = JSON()
loadTask?.cancel()
loadTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
guard let data else { return }
if let json = try? JSON(data: data) {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.json = json
self.sheetViewModel.reset(locationsSettingsGroupImporter)
self.importExportModel.reset(self)
}
}
}
loadTask?.resume()
}
func filename(_ url: URL) -> String {
String(url.lastPathComponent.dropLast(ImportExportSettingsModel.settingsExtension.count + 1))
}
var metadataBuild: String? {
if let build = json.dictionaryValue["metadata"]?.dictionaryValue["build"]?.string {
return build
}
return nil
}
var metadataPlatform: String? {
if let platform = json.dictionaryValue["metadata"]?.dictionaryValue["platform"]?.string {
return platform
}
return nil
}
var metadataDate: String? {
if let timestamp = json.dictionaryValue["metadata"]?.dictionaryValue["timestamp"]?.doubleValue {
let date = Date(timeIntervalSince1970: timestamp)
return dateFormatter.string(from: date)
}
return nil
}
var dateFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.dateStyle = .long
formatter.timeStyle = .medium
return formatter
}
}

View File

@@ -1,64 +0,0 @@
import Defaults
import SwiftyJSON
struct AdvancedSettingsGroupImporter {
var json: JSON
func performImport() {
if let showPlayNowInBackendContextMenu = json["showPlayNowInBackendContextMenu"].bool {
Defaults[.showPlayNowInBackendContextMenu] = showPlayNowInBackendContextMenu
}
if let videoLoadingRetryCount = json["videoLoadingRetryCount"].int {
Defaults[.videoLoadingRetryCount] = videoLoadingRetryCount
}
if let showMPVPlaybackStats = json["showMPVPlaybackStats"].bool {
Defaults[.showMPVPlaybackStats] = showMPVPlaybackStats
}
if let mpvEnableLogging = json["mpvEnableLogging"].bool {
Defaults[.mpvEnableLogging] = mpvEnableLogging
}
if let mpvCacheSecs = json["mpvCacheSecs"].string {
Defaults[.mpvCacheSecs] = mpvCacheSecs
}
if let mpvCachePauseWait = json["mpvCachePauseWait"].string {
Defaults[.mpvCachePauseWait] = mpvCachePauseWait
}
if let mpvCachePauseInital = json["mpvCachePauseInital"].bool {
Defaults[.mpvCachePauseInital] = mpvCachePauseInital
}
if let mpvDeinterlace = json["mpvDeinterlace"].bool {
Defaults[.mpvDeinterlace] = mpvDeinterlace
}
if let mpvHWdec = json["mpvHWdec"].string {
Defaults[.mpvHWdec] = mpvHWdec
}
if let mpvDemuxerLavfProbeInfo = json["mpvDemuxerLavfProbeInfo"].string {
Defaults[.mpvDemuxerLavfProbeInfo] = mpvDemuxerLavfProbeInfo
}
if let mpvSetRefreshToContentFPS = json["mpvSetRefreshToContentFPS"].bool {
Defaults[.mpvSetRefreshToContentFPS] = mpvSetRefreshToContentFPS
}
if let mpvInitialAudioSync = json["mpvInitialAudioSync"].bool {
Defaults[.mpvInitialAudioSync] = mpvInitialAudioSync
}
if let showCacheStatus = json["showCacheStatus"].bool {
Defaults[.showCacheStatus] = showCacheStatus
}
if let feedCacheSize = json["feedCacheSize"].string {
Defaults[.feedCacheSize] = feedCacheSize
}
}
}

View File

@@ -1,148 +0,0 @@
import Defaults
import SwiftyJSON
struct BrowsingSettingsGroupImporter {
var json: JSON
func performImport() {
if let showHome = json["showHome"].bool {
Defaults[.showHome] = showHome
}
if let showOpenActionsInHome = json["showOpenActionsInHome"].bool {
Defaults[.showOpenActionsInHome] = showOpenActionsInHome
}
if let showQueueInHome = json["showQueueInHome"].bool {
Defaults[.showQueueInHome] = showQueueInHome
}
if let showFavoritesInHome = json["showFavoritesInHome"].bool {
Defaults[.showFavoritesInHome] = showFavoritesInHome
}
if let favorites = json["favorites"].array {
for favoriteJSON in favorites {
if let jsonString = favoriteJSON.rawString(options: []),
let item = FavoriteItem.bridge.deserialize(jsonString)
{
FavoritesModel.shared.add(item)
}
}
}
if let widgetsFavorites = json["widgetsSettings"].array {
for widgetJSON in widgetsFavorites {
let dict = widgetJSON.dictionaryValue.mapValues { json in json.stringValue }
if let item = WidgetSettingsBridge().deserialize(dict) {
FavoritesModel.shared.updateWidgetSettings(item)
}
}
}
if let startupSectionString = json["startupSection"].string,
let startupSection = StartupSection(rawValue: startupSectionString)
{
Defaults[.startupSection] = startupSection
}
if let showSearchSuggestions = json["showSearchSuggestions"].bool {
Defaults[.showSearchSuggestions] = showSearchSuggestions
}
if let visibleSections = json["visibleSections"].array {
let sections = visibleSections.compactMap { visibleSectionJSON in
if let visibleSectionString = visibleSectionJSON.rawString(options: []),
let section = VisibleSection(rawValue: visibleSectionString)
{
return section
}
return nil
}
Defaults[.visibleSections] = Set(sections)
}
#if os(iOS)
if let showOpenActionsToolbarItem = json["showOpenActionsToolbarItem"].bool {
Defaults[.showOpenActionsToolbarItem] = showOpenActionsToolbarItem
}
if let lockPortraitWhenBrowsing = json["lockPortraitWhenBrowsing"].bool {
Defaults[.lockPortraitWhenBrowsing] = lockPortraitWhenBrowsing
}
#endif
#if !os(tvOS)
if let accountPickerDisplaysUsername = json["accountPickerDisplaysUsername"].bool {
Defaults[.accountPickerDisplaysUsername] = accountPickerDisplaysUsername
}
#endif
if let accountPickerDisplaysAnonymousAccounts = json["accountPickerDisplaysAnonymousAccounts"].bool {
Defaults[.accountPickerDisplaysAnonymousAccounts] = accountPickerDisplaysAnonymousAccounts
}
if let showUnwatchedFeedBadges = json["showUnwatchedFeedBadges"].bool {
Defaults[.showUnwatchedFeedBadges] = showUnwatchedFeedBadges
}
if let expandChannelDescription = json["expandChannelDescription"].bool {
Defaults[.expandChannelDescription] = expandChannelDescription
}
if let keepChannelsWithUnwatchedFeedOnTop = json["keepChannelsWithUnwatchedFeedOnTop"].bool {
Defaults[.keepChannelsWithUnwatchedFeedOnTop] = keepChannelsWithUnwatchedFeedOnTop
}
if let showChannelAvatarInChannelsLists = json["showChannelAvatarInChannelsLists"].bool {
Defaults[.showChannelAvatarInChannelsLists] = showChannelAvatarInChannelsLists
}
if let showChannelAvatarInVideosListing = json["showChannelAvatarInVideosListing"].bool {
Defaults[.showChannelAvatarInVideosListing] = showChannelAvatarInVideosListing
}
if let playerButtonSingleTapGestureString = json["playerButtonSingleTapGesture"].string,
let playerButtonSingleTapGesture = PlayerTapGestureAction(rawValue: playerButtonSingleTapGestureString)
{
Defaults[.playerButtonSingleTapGesture] = playerButtonSingleTapGesture
}
if let playerButtonDoubleTapGestureString = json["playerButtonDoubleTapGesture"].string,
let playerButtonDoubleTapGesture = PlayerTapGestureAction(rawValue: playerButtonDoubleTapGestureString)
{
Defaults[.playerButtonDoubleTapGesture] = playerButtonDoubleTapGesture
}
if let playerButtonShowsControlButtonsWhenMinimized = json["playerButtonShowsControlButtonsWhenMinimized"].bool {
Defaults[.playerButtonShowsControlButtonsWhenMinimized] = playerButtonShowsControlButtonsWhenMinimized
}
if let playerButtonIsExpanded = json["playerButtonIsExpanded"].bool {
Defaults[.playerButtonIsExpanded] = playerButtonIsExpanded
}
if let playerBarMaxWidth = json["playerBarMaxWidth"].string {
Defaults[.playerBarMaxWidth] = playerBarMaxWidth
}
if let channelOnThumbnail = json["channelOnThumbnail"].bool {
Defaults[.channelOnThumbnail] = channelOnThumbnail
}
if let timeOnThumbnail = json["timeOnThumbnail"].bool {
Defaults[.timeOnThumbnail] = timeOnThumbnail
}
if let roundedThumbnails = json["roundedThumbnails"].bool {
Defaults[.roundedThumbnails] = roundedThumbnails
}
if let thumbnailsQualityString = json["thumbnailsQuality"].string,
let thumbnailsQuality = ThumbnailsQuality(rawValue: thumbnailsQualityString)
{
Defaults[.thumbnailsQuality] = thumbnailsQuality
}
}
}

View File

@@ -1,148 +0,0 @@
import Defaults
import SwiftyJSON
struct ConstrolsSettingsGroupImporter {
var json: JSON
func performImport() {
if let avPlayerUsesSystemControls = json["avPlayerUsesSystemControls"].bool {
Defaults[.avPlayerUsesSystemControls] = avPlayerUsesSystemControls
}
if let fullscreenPlayerGestureEnabled = json["fullscreenPlayerGestureEnabled"].bool {
Defaults[.fullscreenPlayerGestureEnabled] = fullscreenPlayerGestureEnabled
}
if let horizontalPlayerGestureEnabled = json["horizontalPlayerGestureEnabled"].bool {
Defaults[.horizontalPlayerGestureEnabled] = horizontalPlayerGestureEnabled
}
if let seekGestureSensitivity = json["seekGestureSensitivity"].double {
Defaults[.seekGestureSensitivity] = seekGestureSensitivity
}
if let seekGestureSpeed = json["seekGestureSpeed"].double {
Defaults[.seekGestureSpeed] = seekGestureSpeed
}
if let playerControlsLayoutString = json["playerControlsLayout"].string,
let playerControlsLayout = PlayerControlsLayout(rawValue: playerControlsLayoutString)
{
Defaults[.playerControlsLayout] = playerControlsLayout
}
if let fullScreenPlayerControlsLayoutString = json["fullScreenPlayerControlsLayout"].string,
let fullScreenPlayerControlsLayout = PlayerControlsLayout(rawValue: fullScreenPlayerControlsLayoutString)
{
Defaults[.fullScreenPlayerControlsLayout] = fullScreenPlayerControlsLayout
}
if let playerControlsBackgroundOpacity = json["playerControlsBackgroundOpacity"].double {
Defaults[.playerControlsBackgroundOpacity] = playerControlsBackgroundOpacity
}
if let systemControlsCommandsString = json["systemControlsCommands"].string,
let systemControlsCommands = SystemControlsCommands(rawValue: systemControlsCommandsString)
{
Defaults[.systemControlsCommands] = systemControlsCommands
}
if let buttonBackwardSeekDuration = json["buttonBackwardSeekDuration"].string {
Defaults[.buttonBackwardSeekDuration] = buttonBackwardSeekDuration
}
if let buttonForwardSeekDuration = json["buttonForwardSeekDuration"].string {
Defaults[.buttonForwardSeekDuration] = buttonForwardSeekDuration
}
if let gestureBackwardSeekDuration = json["gestureBackwardSeekDuration"].string {
Defaults[.gestureBackwardSeekDuration] = gestureBackwardSeekDuration
}
if let gestureForwardSeekDuration = json["gestureForwardSeekDuration"].string {
Defaults[.gestureForwardSeekDuration] = gestureForwardSeekDuration
}
if let systemControlsSeekDuration = json["systemControlsSeekDuration"].string {
Defaults[.systemControlsSeekDuration] = systemControlsSeekDuration
}
if let playerControlsSettingsEnabled = json["playerControlsSettingsEnabled"].bool {
Defaults[.playerControlsSettingsEnabled] = playerControlsSettingsEnabled
}
if let playerControlsCloseEnabled = json["playerControlsCloseEnabled"].bool {
Defaults[.playerControlsCloseEnabled] = playerControlsCloseEnabled
}
if let playerControlsRestartEnabled = json["playerControlsRestartEnabled"].bool {
Defaults[.playerControlsRestartEnabled] = playerControlsRestartEnabled
}
if let playerControlsAdvanceToNextEnabled = json["playerControlsAdvanceToNextEnabled"].bool {
Defaults[.playerControlsAdvanceToNextEnabled] = playerControlsAdvanceToNextEnabled
}
if let playerControlsPlaybackModeEnabled = json["playerControlsPlaybackModeEnabled"].bool {
Defaults[.playerControlsPlaybackModeEnabled] = playerControlsPlaybackModeEnabled
}
if let playerControlsMusicModeEnabled = json["playerControlsMusicModeEnabled"].bool {
Defaults[.playerControlsMusicModeEnabled] = playerControlsMusicModeEnabled
}
if let playerActionsButtonLabelStyleString = json["playerActionsButtonLabelStyle"].string,
let playerActionsButtonLabelStyle = ButtonLabelStyle(rawValue: playerActionsButtonLabelStyleString)
{
Defaults[.playerActionsButtonLabelStyle] = playerActionsButtonLabelStyle
}
if let actionButtonShareEnabled = json["actionButtonShareEnabled"].bool {
Defaults[.actionButtonShareEnabled] = actionButtonShareEnabled
}
if let actionButtonAddToPlaylistEnabled = json["actionButtonAddToPlaylistEnabled"].bool {
Defaults[.actionButtonAddToPlaylistEnabled] = actionButtonAddToPlaylistEnabled
}
if let actionButtonSubscribeEnabled = json["actionButtonSubscribeEnabled"].bool {
Defaults[.actionButtonSubscribeEnabled] = actionButtonSubscribeEnabled
}
if let actionButtonSettingsEnabled = json["actionButtonSettingsEnabled"].bool {
Defaults[.actionButtonSettingsEnabled] = actionButtonSettingsEnabled
}
if let actionButtonHideEnabled = json["actionButtonHideEnabled"].bool {
Defaults[.actionButtonHideEnabled] = actionButtonHideEnabled
}
if let actionButtonCloseEnabled = json["actionButtonCloseEnabled"].bool {
Defaults[.actionButtonCloseEnabled] = actionButtonCloseEnabled
}
if let actionButtonFullScreenEnabled = json["actionButtonFullScreenEnabled"].bool {
Defaults[.actionButtonFullScreenEnabled] = actionButtonFullScreenEnabled
}
if let actionButtonPipEnabled = json["actionButtonPipEnabled"].bool {
Defaults[.actionButtonPipEnabled] = actionButtonPipEnabled
}
if let actionButtonLockOrientationEnabled = json["actionButtonLockOrientationEnabled"].bool {
Defaults[.actionButtonLockOrientationEnabled] = actionButtonLockOrientationEnabled
}
if let actionButtonRestartEnabled = json["actionButtonRestartEnabled"].bool {
Defaults[.actionButtonRestartEnabled] = actionButtonRestartEnabled
}
if let actionButtonAdvanceToNextItemEnabled = json["actionButtonAdvanceToNextItemEnabled"].bool {
Defaults[.actionButtonAdvanceToNextItemEnabled] = actionButtonAdvanceToNextItemEnabled
}
if let actionButtonMusicModeEnabled = json["actionButtonMusicModeEnabled"].bool {
Defaults[.actionButtonMusicModeEnabled] = actionButtonMusicModeEnabled
}
}
}

View File

@@ -1,66 +0,0 @@
import Defaults
import SwiftyJSON
struct HistorySettingsGroupImporter {
var json: JSON
func performImport() {
if let saveRecents = json["saveRecents"].bool {
Defaults[.saveRecents] = saveRecents
}
if let saveHistory = json["saveHistory"].bool {
Defaults[.saveHistory] = saveHistory
}
if let showRecents = json["showRecents"].bool {
Defaults[.showRecents] = showRecents
}
if let limitRecents = json["limitRecents"].bool {
Defaults[.limitRecents] = limitRecents
}
if let limitRecentsAmount = json["limitRecentsAmount"].int {
Defaults[.limitRecentsAmount] = limitRecentsAmount
}
if let showWatchingProgress = json["showWatchingProgress"].bool {
Defaults[.showWatchingProgress] = showWatchingProgress
}
if let saveLastPlayed = json["saveLastPlayed"].bool {
Defaults[.saveLastPlayed] = saveLastPlayed
}
if let watchedVideoPlayNowBehaviorString = json["watchedVideoPlayNowBehavior"].string,
let watchedVideoPlayNowBehavior = WatchedVideoPlayNowBehavior(rawValue: watchedVideoPlayNowBehaviorString)
{
Defaults[.watchedVideoPlayNowBehavior] = watchedVideoPlayNowBehavior
}
if let watchedThreshold = json["watchedThreshold"].int {
Defaults[.watchedThreshold] = watchedThreshold
}
if let resetWatchedStatusOnPlaying = json["resetWatchedStatusOnPlaying"].bool {
Defaults[.resetWatchedStatusOnPlaying] = resetWatchedStatusOnPlaying
}
if let watchedVideoStyleString = json["watchedVideoStyle"].string,
let watchedVideoStyle = WatchedVideoStyle(rawValue: watchedVideoStyleString)
{
Defaults[.watchedVideoStyle] = watchedVideoStyle
}
if let watchedVideoBadgeColorString = json["watchedVideoBadgeColor"].string,
let watchedVideoBadgeColor = WatchedVideoBadgeColor(rawValue: watchedVideoBadgeColorString)
{
Defaults[.watchedVideoBadgeColor] = watchedVideoBadgeColor
}
if let showToggleWatchedStatusButton = json["showToggleWatchedStatusButton"].bool {
Defaults[.showToggleWatchedStatusButton] = showToggleWatchedStatusButton
}
}
}

View File

@@ -1,84 +0,0 @@
import Defaults
import SwiftyJSON
struct LocationsSettingsGroupImporter {
var json: JSON
var includePublicLocations = true
var includedInstancesIDs = Set<Instance.ID>()
var includedAccountsIDs = Set<Account.ID>()
var includedAccountsPasswords = [Account.ID: String]()
init(
json: JSON,
includePublicLocations: Bool = true,
includedInstancesIDs: Set<Instance.ID> = [],
includedAccountsIDs: Set<Account.ID> = [],
includedAccountsPasswords: [Account.ID: String] = [:]
) {
self.json = json
self.includePublicLocations = includePublicLocations
self.includedInstancesIDs = includedInstancesIDs
self.includedAccountsIDs = includedAccountsIDs
self.includedAccountsPasswords = includedAccountsPasswords
}
var instances: [Instance] {
if let instances = json["instances"].array {
return instances.compactMap { instanceJSON in
let dict = instanceJSON.dictionaryValue.mapValues { json in json.stringValue }
return InstancesBridge().deserialize(dict)
}
}
return []
}
var accounts: [Account] {
if let accounts = json["accounts"].array {
return accounts.compactMap { accountJSON in
let dict = accountJSON.dictionaryValue.mapValues { json in json.stringValue }
return AccountsBridge().deserialize(dict)
}
}
return []
}
func performImport() {
if includePublicLocations {
Defaults[.instancesManifest] = json["instancesManifest"].string ?? ""
Defaults[.countryOfPublicInstances] = json["countryOfPublicInstances"].string ?? ""
}
instances.filter { includedInstancesIDs.contains($0.id) }.forEach { instance in
_ = InstancesModel.shared.insert(id: instance.id, app: instance.app, name: instance.name, url: instance.apiURLString)
}
if let accounts = json["accounts"].array {
for accountJSON in accounts {
let dict = accountJSON.dictionaryValue.mapValues { json in json.stringValue }
if let account = AccountsBridge().deserialize(dict),
includedAccountsIDs.contains(account.id)
{
var password = account.password
if password?.isEmpty ?? true {
password = includedAccountsPasswords[account.id]
}
if let password,
!password.isEmpty,
let instanceID = account.instanceID,
let instance = InstancesModel.shared.find(instanceID) ?? InstancesModel.shared.findByURLString(account.urlString)
{
if !instance.accounts.contains(where: { instanceAccount in
let (username, _) = instanceAccount.credentials
return username == account.username
}) {
_ = AccountsModel.add(instance: instance, id: account.id, name: account.name, username: account.username, password: password)
}
}
}
}
}
}
}

View File

@@ -1,70 +0,0 @@
import Defaults
import SwiftyJSON
struct OtherDataSettingsGroupImporter {
var json: JSON
func performImport() {
if let lastAccountID = json["lastAccountID"].string {
Defaults[.lastAccountID] = lastAccountID
}
if let lastInstanceID = json["lastInstanceID"].string {
Defaults[.lastInstanceID] = lastInstanceID
}
if let playerRate = json["playerRate"].double {
Defaults[.playerRate] = playerRate
}
if let trendingCategoryString = json["trendingCategory"].string,
let trendingCategory = TrendingCategory(rawValue: trendingCategoryString)
{
Defaults[.trendingCategory] = trendingCategory
}
if let trendingCountryString = json["trendingCountry"].string,
let trendingCountry = Country(rawValue: trendingCountryString)
{
Defaults[.trendingCountry] = trendingCountry
}
if let subscriptionsViewPageString = json["subscriptionsViewPage"].string,
let subscriptionsViewPage = SubscriptionsView.Page(rawValue: subscriptionsViewPageString)
{
Defaults[.subscriptionsViewPage] = subscriptionsViewPage
}
if let subscriptionsListingStyle = json["subscriptionsListingStyle"].string {
Defaults[.subscriptionsListingStyle] = ListingStyle(rawValue: subscriptionsListingStyle) ?? .list
}
if let popularListingStyle = json["popularListingStyle"].string {
Defaults[.popularListingStyle] = ListingStyle(rawValue: popularListingStyle) ?? .list
}
if let trendingListingStyle = json["trendingListingStyle"].string {
Defaults[.trendingListingStyle] = ListingStyle(rawValue: trendingListingStyle) ?? .list
}
if let playlistListingStyle = json["playlistListingStyle"].string {
Defaults[.playlistListingStyle] = ListingStyle(rawValue: playlistListingStyle) ?? .list
}
if let channelPlaylistListingStyle = json["channelPlaylistListingStyle"].string {
Defaults[.channelPlaylistListingStyle] = ListingStyle(rawValue: channelPlaylistListingStyle) ?? .list
}
if let searchListingStyle = json["searchListingStyle"].string {
Defaults[.searchListingStyle] = ListingStyle(rawValue: searchListingStyle) ?? .list
}
if let hideShorts = json["hideShorts"].bool {
Defaults[.hideShorts] = hideShorts
}
if let hideWatched = json["hideWatched"].bool {
Defaults[.hideWatched] = hideWatched
}
}
}

View File

@@ -1,135 +0,0 @@
import Defaults
import SwiftyJSON
struct PlayerSettingsGroupImporter {
var json: JSON
func performImport() {
if let playerInstanceID = json["playerInstanceID"].string {
Defaults[.playerInstanceID] = playerInstanceID
}
if let pauseOnHidingPlayer = json["pauseOnHidingPlayer"].bool {
Defaults[.pauseOnHidingPlayer] = pauseOnHidingPlayer
}
if let closeVideoOnEOF = json["closeVideoOnEOF"].bool {
Defaults[.closeVideoOnEOF] = closeVideoOnEOF
}
if let exitFullscreenOnEOF = json["exitFullscreenOnEOF"].bool {
Defaults[.exitFullscreenOnEOF] = exitFullscreenOnEOF
}
if let expandVideoDescription = json["expandVideoDescription"].bool {
Defaults[.expandVideoDescription] = expandVideoDescription
}
if let collapsedLinesDescription = json["collapsedLinesDescription"].int {
Defaults[.collapsedLinesDescription] = collapsedLinesDescription
}
if let showChapters = json["showChapters"].bool {
Defaults[.showChapters] = showChapters
}
if let showChapterThumbnails = json["showChapterThumbnails"].bool {
Defaults[.showChapterThumbnails] = showChapterThumbnails
}
if let showChapterThumbnailsOnlyWhenDifferent = json["showChapterThumbnailsOnlyWhenDifferent"].bool {
Defaults[.showChapterThumbnailsOnlyWhenDifferent] = showChapterThumbnailsOnlyWhenDifferent
}
if let expandChapters = json["expandChapters"].bool {
Defaults[.expandChapters] = expandChapters
}
if let showRelated = json["showRelated"].bool {
Defaults[.showRelated] = showRelated
}
if let showInspectorString = json["showInspector"].string,
let showInspector = ShowInspectorSetting(rawValue: showInspectorString)
{
Defaults[.showInspector] = showInspector
}
if let playerSidebarString = json["playerSidebar"].string,
let playerSidebar = PlayerSidebarSetting(rawValue: playerSidebarString)
{
Defaults[.playerSidebar] = playerSidebar
}
if let showKeywords = json["showKeywords"].bool {
Defaults[.showKeywords] = showKeywords
}
if let enableReturnYouTubeDislike = json["enableReturnYouTubeDislike"].bool {
Defaults[.enableReturnYouTubeDislike] = enableReturnYouTubeDislike
}
if let closePiPOnNavigation = json["closePiPOnNavigation"].bool {
Defaults[.closePiPOnNavigation] = closePiPOnNavigation
}
if let closePiPOnOpeningPlayer = json["closePiPOnOpeningPlayer"].bool {
Defaults[.closePiPOnOpeningPlayer] = closePiPOnOpeningPlayer
}
if let closePlayerOnOpeningPiP = json["closePlayerOnOpeningPiP"].bool {
Defaults[.closePlayerOnOpeningPiP] = closePlayerOnOpeningPiP
}
#if !os(macOS)
if let pauseOnEnteringBackground = json["pauseOnEnteringBackground"].bool {
Defaults[.pauseOnEnteringBackground] = pauseOnEnteringBackground
}
#endif
if let showComments = json["showComments"].bool {
Defaults[.showComments] = showComments
}
#if !os(tvOS)
if let showScrollToTopInComments = json["showScrollToTopInComments"].bool {
Defaults[.showScrollToTopInComments] = showScrollToTopInComments
}
#endif
#if os(iOS)
if let isOrientationLocked = json["isOrientationLocked"].bool {
Defaults[.isOrientationLocked] = isOrientationLocked
}
if let enterFullscreenInLandscape = json["enterFullscreenInLandscape"].bool {
Defaults[.enterFullscreenInLandscape] = enterFullscreenInLandscape
}
if let rotateToLandscapeOnEnterFullScreenString = json["rotateToLandscapeOnEnterFullScreen"].string,
let rotateToLandscapeOnEnterFullScreen = FullScreenRotationSetting(rawValue: rotateToLandscapeOnEnterFullScreenString)
{
Defaults[.rotateToLandscapeOnEnterFullScreen] = rotateToLandscapeOnEnterFullScreen
}
#endif
if let captionsAutoShow = json["captionsAutoShow"].bool {
Defaults[.captionsAutoShow] = captionsAutoShow
}
if let captionsDefaultLanguageCode = json["captionsDefaultLanguageCode"].string {
Defaults[.captionsDefaultLanguageCode] = captionsDefaultLanguageCode
}
if let captionsFallbackLanguageCode = json["captionsFallbackLanguageCode"].string {
Defaults[.captionsFallbackLanguageCode] = captionsFallbackLanguageCode
}
if let captionsFontScaleSize = json["captionsFontScaleSize"].string {
Defaults[.captionsFontScaleSize] = captionsFontScaleSize
}
if let captionsFontColor = json["captionsFontColor"].string {
Defaults[.captionsFontColor] = captionsFontColor
}
}
}

View File

@@ -1,37 +0,0 @@
import Defaults
import SwiftyJSON
struct QualitySettingsGroupImporter {
var json: JSON
func performImport() {
if let batteryCellularProfileString = json["batteryCellularProfile"].string {
Defaults[.batteryCellularProfile] = batteryCellularProfileString
}
if let batteryNonCellularProfileString = json["batteryNonCellularProfile"].string {
Defaults[.batteryNonCellularProfile] = batteryNonCellularProfileString
}
if let chargingCellularProfileString = json["chargingCellularProfile"].string {
Defaults[.chargingCellularProfile] = chargingCellularProfileString
}
if let chargingNonCellularProfileString = json["chargingNonCellularProfile"].string {
Defaults[.chargingNonCellularProfile] = chargingNonCellularProfileString
}
if let forceAVPlayerForLiveStreams = json["forceAVPlayerForLiveStreams"].bool {
Defaults[.forceAVPlayerForLiveStreams] = forceAVPlayerForLiveStreams
}
if let qualityProfiles = json["qualityProfiles"].array {
for qualityProfileJSON in qualityProfiles {
let dict = qualityProfileJSON.dictionaryValue.mapValues { json in json.stringValue }
if let item = QualityProfileBridge().deserialize(dict) {
QualityProfilesModel.shared.update(item, item)
}
}
}
}
}

View File

@@ -1,17 +0,0 @@
import Defaults
import SwiftyJSON
struct RecentlyOpenedImporter {
var json: JSON
func performImport() {
if let recentlyOpened = json["recentlyOpened"].array {
for recentlyOpenedJSON in recentlyOpened {
let dict = recentlyOpenedJSON.dictionaryValue.mapValues { json in json.stringValue }
if let item = RecentItemBridge().deserialize(dict) {
RecentsModel.shared.add(item)
}
}
}
}
}

View File

@@ -1,33 +0,0 @@
import Defaults
import SwiftyJSON
struct SponsorBlockSettingsGroupImporter {
var json: JSON
func performImport() {
if let sponsorBlockInstance = json["sponsorBlockInstance"].string {
Defaults[.sponsorBlockInstance] = sponsorBlockInstance
}
if let sponsorBlockCategories = json["sponsorBlockCategories"].array {
Defaults[.sponsorBlockCategories] = Set(sponsorBlockCategories.compactMap(\.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
}
}
}

Some files were not shown because too many files have changed in this diff Show More