Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37a315e75a | ||
|
|
f47d8ed752 |
15
.env.example
@@ -1,15 +0,0 @@
|
||||
# Yattee UI Test Configuration
|
||||
# Copy this file to .env and fill in your values
|
||||
|
||||
# Invidious credentials for UI tests (required for import_subscriptions tests)
|
||||
INVIDIOUS_EMAIL=
|
||||
INVIDIOUS_PASSWORD=
|
||||
|
||||
# Piped credentials for UI tests (required for import tests)
|
||||
PIPED_USERNAME=
|
||||
PIPED_PASSWORD=
|
||||
|
||||
# Optional: Override default test URLs
|
||||
# YATTEE_SERVER_URL=https://yp.home.arekf.net
|
||||
# INVIDIOUS_URL=https://invidious.home.arekf.net
|
||||
# PIPED_URL=https://pipedapi.home.arekf.net
|
||||
5
.github/FUNDING.yml
vendored
@@ -1,5 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: yattee
|
||||
patreon: arekf
|
||||
custom: https://github.com/yattee/yattee/wiki/Donations
|
||||
74
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
@@ -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
|
||||
1
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1 +0,0 @@
|
||||
blank_issues_enabled: true
|
||||
39
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
@@ -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
|
||||
@@ -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
|
||||
35
.github/workflows/bump-build.yml
vendored
@@ -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 }}
|
||||
|
||||
|
||||
108
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
21
.gitignore
vendored
@@ -81,9 +81,6 @@ fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots/**/*.png
|
||||
fastlane/test_output
|
||||
fastlane/builds
|
||||
fastlane/.env
|
||||
fastlane/*.p8
|
||||
|
||||
# Code Injection
|
||||
#
|
||||
@@ -94,21 +91,3 @@ iOSInjectionProject/
|
||||
|
||||
# SwiftLint Remote Config Cache
|
||||
.swiftlint/RemoteConfigCache
|
||||
|
||||
# User-specific xcconfig files
|
||||
Xcode-config/DEVELOPMENT_TEAM.xcconfig
|
||||
|
||||
# Bundler
|
||||
.bundle/
|
||||
Vendor/bundle/
|
||||
|
||||
# Code Coverage
|
||||
coverage/
|
||||
|
||||
# UI Test Snapshots (keep baseline/, ignore generated files)
|
||||
spec/ui_snapshots/current/
|
||||
spec/ui_snapshots/diff/
|
||||
|
||||
# Environment variables (contains secrets)
|
||||
.env
|
||||
.env.local
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
project: Yattee.xcodeproj
|
||||
schemes:
|
||||
- Yattee
|
||||
120
.rubocop.yml
@@ -1,120 +0,0 @@
|
||||
# RuboCop configuration for Yattee UI tests
|
||||
# Relaxed configuration matching existing code style
|
||||
|
||||
plugins:
|
||||
- rubocop-rspec
|
||||
|
||||
AllCops:
|
||||
TargetRubyVersion: 3.4
|
||||
NewCops: enable
|
||||
Include:
|
||||
- 'spec/**/*.rb'
|
||||
Exclude:
|
||||
- 'vendor/**/*'
|
||||
- 'Gemfile'
|
||||
|
||||
# ============================================
|
||||
# Metrics - Relaxed for UI test complexity
|
||||
# ============================================
|
||||
|
||||
# RSpec blocks are naturally long
|
||||
Metrics/BlockLength:
|
||||
Enabled: false
|
||||
|
||||
# UI test methods can be longer
|
||||
Metrics/MethodLength:
|
||||
Max: 120
|
||||
|
||||
# Classes can be larger in test support code
|
||||
Metrics/ClassLength:
|
||||
Max: 500
|
||||
|
||||
# Allow higher complexity for UI test helpers
|
||||
Metrics/AbcSize:
|
||||
Max: 100
|
||||
|
||||
Metrics/CyclomaticComplexity:
|
||||
Max: 40
|
||||
|
||||
Metrics/PerceivedComplexity:
|
||||
Max: 40
|
||||
|
||||
# ============================================
|
||||
# Layout
|
||||
# ============================================
|
||||
|
||||
# Relaxed line length for readability
|
||||
Layout/LineLength:
|
||||
Max: 140
|
||||
|
||||
# ============================================
|
||||
# Naming
|
||||
# ============================================
|
||||
|
||||
# Allow methods like find_element, ensure_invidious without ? suffix
|
||||
Naming/PredicateMethod:
|
||||
Enabled: false
|
||||
|
||||
# Allow set_ prefix for methods like set_status_bar_overrides
|
||||
Naming/AccessorMethodName:
|
||||
Enabled: false
|
||||
|
||||
# ============================================
|
||||
# Lint
|
||||
# ============================================
|
||||
|
||||
# Allow duplicate branches in case statements (UI state machines)
|
||||
Lint/DuplicateBranch:
|
||||
Enabled: false
|
||||
|
||||
# ============================================
|
||||
# Style
|
||||
# ============================================
|
||||
|
||||
# Not needed for test files
|
||||
Style/Documentation:
|
||||
Enabled: false
|
||||
|
||||
# Allow multi-line block chains (common in RSpec)
|
||||
Style/MultilineBlockChain:
|
||||
Enabled: false
|
||||
|
||||
# Allow single-line if statements (don't force modifier style)
|
||||
Style/IfUnlessModifier:
|
||||
Enabled: false
|
||||
|
||||
# Allow short parameter names in UI test helpers
|
||||
Naming/MethodParameterName:
|
||||
Enabled: false
|
||||
|
||||
# ============================================
|
||||
# RSpec - Relaxed for UI testing patterns
|
||||
# ============================================
|
||||
|
||||
# UI tests may need more steps
|
||||
RSpec/ExampleLength:
|
||||
Max: 30
|
||||
|
||||
# Allow before(:all) for simulator lifecycle management
|
||||
RSpec/BeforeAfterAll:
|
||||
Enabled: false
|
||||
|
||||
# Allow instance variables shared across examples (@axe, @udid)
|
||||
RSpec/InstanceVariable:
|
||||
Enabled: false
|
||||
|
||||
# UI tests often batch multiple checks
|
||||
RSpec/MultipleExpectations:
|
||||
Enabled: false
|
||||
|
||||
# Allow deeper nesting for describe/context blocks
|
||||
RSpec/NestedGroups:
|
||||
Max: 5
|
||||
|
||||
# Feature/smoke specs use string descriptions, not class names
|
||||
RSpec/DescribeClass:
|
||||
Enabled: false
|
||||
|
||||
# Allow expect in before hooks for setup verification
|
||||
RSpec/ExpectInHook:
|
||||
Enabled: false
|
||||
@@ -1 +0,0 @@
|
||||
3.4.8
|
||||
16
.slather.yml
@@ -1,16 +0,0 @@
|
||||
# Slather Code Coverage Configuration
|
||||
# https://github.com/SlatherOrg/slather
|
||||
|
||||
proj: Yattee.xcodeproj
|
||||
scheme: Yattee
|
||||
build-directory: DerivedData
|
||||
output-directory: coverage
|
||||
|
||||
# Coverage format (options: html, cobertura, llvm-cov, json, sonarqube, gutter-json)
|
||||
coverage_service: html
|
||||
|
||||
# Ignore patterns - adjust as needed
|
||||
ignore:
|
||||
- "**/YatteeTests/**"
|
||||
- "**/Vendor/**"
|
||||
- "**/*.generated.swift"
|
||||
1
.swift-version
Normal file
@@ -0,0 +1 @@
|
||||
5
|
||||
2
.swiftformat
Normal file
@@ -0,0 +1,2 @@
|
||||
--disable trailingCommas
|
||||
--exclude Tests*
|
||||
12
.swiftlint.yml
Normal file
@@ -0,0 +1,12 @@
|
||||
parent_config: https://raw.githubusercontent.com/sindresorhus/swiftlint-config/main/.swiftlint.yml
|
||||
|
||||
disabled_rules:
|
||||
- identifier_name
|
||||
- opening_brace
|
||||
- number_separator
|
||||
- multiline_arguments
|
||||
|
||||
excluded:
|
||||
- Tests Apple TV
|
||||
- Tests iOS
|
||||
- Tests macOS
|
||||
59
AGENTS.md
@@ -1,59 +0,0 @@
|
||||
# Yattee Development Guide for AI Agents
|
||||
|
||||
## Deployment Targets
|
||||
|
||||
**iOS:** 18.0+ | **macOS:** 15.0+ | **tvOS:** 18.0+
|
||||
|
||||
This project targets the latest OS versions only - use newest APIs freely without availability checks.
|
||||
|
||||
## Build & Test Commands
|
||||
|
||||
**Build:** `xcodebuild -scheme Yattee -configuration Debug`
|
||||
**Test (all):** `xcodebuild test -scheme Yattee -destination 'platform=macOS'`
|
||||
**Test (single):** `xcodebuild test -scheme Yattee -destination 'platform=macOS' -only-testing:YatteeTests/TestSuiteName/testMethodName`
|
||||
**Lint:** `periphery scan` (config: `.periphery.yml`)
|
||||
|
||||
## Code Style
|
||||
|
||||
**Language:** Swift 5.0+ with strict concurrency (Swift 6 mode enabled)
|
||||
**UI:** SwiftUI with `@Observable` macro for view models (not `ObservableObject`)
|
||||
**Concurrency:** Use `actor` for services, `@MainActor` for UI-related code, `async/await` everywhere
|
||||
**Testing:** Swift Testing framework (`@Test`, `@Suite`, `#expect`) - NOT XCTest
|
||||
|
||||
## Imports & Organization
|
||||
|
||||
**Import order:** Foundation first, then SwiftUI, then @testable imports
|
||||
**File headers:** Include `// FileName.swift`, `// Yattee`, and brief comment describing purpose
|
||||
**MARK comments:** Use `// MARK: - Section Name` to organize code sections
|
||||
**Sendable:** All models, errors, and actors must conform to `Sendable`
|
||||
|
||||
## Types & Naming
|
||||
|
||||
**Models:** Immutable structs with `Codable, Hashable, Sendable` conformance
|
||||
**Services:** Use `actor` for thread-safe services, `final class` for `@Observable` view models
|
||||
**Enums:** Use associated values for typed errors (see `APIError.swift`)
|
||||
**Optionals:** Prefer guard-let unwrapping; use `if let` for simple cases
|
||||
**Naming:** camelCase for variables/functions, PascalCase for types, clear descriptive names
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Errors:** Define typed enum errors conforming to `Error, LocalizedError, Equatable, Sendable`
|
||||
**Async throws:** All async network/IO operations should throw typed errors
|
||||
**Logging:** Use `LoggingService.shared` for all logging (see `HTTPClient.swift` for patterns)
|
||||
**User feedback:** Provide localized error descriptions via `errorDescription`
|
||||
|
||||
## Testing & Debugging
|
||||
|
||||
**Add logging/visual clues** (borders, backgrounds) when debugging issues - then ask user for results
|
||||
**If first fix doesn't work:** Add debug code before second attempt to understand the issue better
|
||||
|
||||
## UI Testing (Ruby/RSpec with AXe CLI)
|
||||
|
||||
**Run UI tests:** `./bin/ui-test --skip-build --keep-simulator`
|
||||
**Run single spec:** `SKIP_BUILD=1 KEEP_SIMULATOR=1 bundle exec rspec spec/ui/smoke/search_spec.rb`
|
||||
|
||||
**Accessibility labels vs identifiers:** On iOS 26+, `.accessibilityIdentifier()` doesn't work reliably on `Group`, `ScrollView`, and some container views (AXUniqueId comes back empty). Use `.accessibilityLabel()` instead, which maps to `AXLabel` and can be detected via AXe's `text_visible?()` method.
|
||||
|
||||
**iOS 26 TabView search:** The search field is integrated into the bottom tab bar with `Tab(role: .search)`. Typing `\n` doesn't submit - use hardware key press via `press_return` (AXe key 40).
|
||||
|
||||
**ScrollView children:** Video rows inside `LazyVStack`/`ScrollView` aren't exposed in the accessibility tree. Use coordinate-based tapping instead.
|
||||
13
Backports/Backport.swift
Normal file
@@ -0,0 +1,13 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct Backport<Content> {
|
||||
public let content: Content
|
||||
|
||||
public init(_ content: Content) {
|
||||
self.content = content
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
var backport: Backport<Self> { Backport(self) }
|
||||
}
|
||||
11
Backports/Badge+Backport.swift
Normal file
@@ -0,0 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func badge(_ count: Text) -> some View {
|
||||
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
|
||||
content.badge(count)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Backports/Tint+Backport.swift
Normal file
@@ -0,0 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func tint(_ color: Color?) -> some View {
|
||||
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
|
||||
content.tint(color)
|
||||
} else {
|
||||
content.foregroundColor(color)
|
||||
}
|
||||
}
|
||||
}
|
||||
162
CLAUDE.md
@@ -1,162 +0,0 @@
|
||||
# Yattee Development Notes
|
||||
|
||||
## Testing Instances
|
||||
|
||||
- **Invidious**: `https://invidious.home.arekf.net/` - Use this instance for testing API calls
|
||||
- **Yattee Server**: `https://main.s.yattee.stream` - Local self-hosted Yattee server for backend testing
|
||||
|
||||
## Related Projects
|
||||
|
||||
### Yattee Server
|
||||
|
||||
Location: `~/Developer/yattee-server`
|
||||
|
||||
A self-hosted API server powered by yt-dlp that provides an Invidious-compatible API for YouTube content. Used as an alternative backend when Invidious/Piped instances are blocked or unavailable.
|
||||
|
||||
**Key features:**
|
||||
- Invidious-compatible API endpoints (`/api/v1/videos`, `/api/v1/channels`, `/api/v1/search`, etc.)
|
||||
- Uses yt-dlp with deno for YouTube JS challenge solving
|
||||
- Returns direct YouTube CDN stream URLs
|
||||
- Optional backing Invidious instance for trending, popular, and search suggestions
|
||||
|
||||
**API endpoints:**
|
||||
- `GET /api/v1/videos/{video_id}` - Video metadata and streams
|
||||
- `GET /api/v1/channels/{channel_id}` - Channel info
|
||||
- `GET /api/v1/channels/{channel_id}/videos` - Channel videos
|
||||
- `GET /api/v1/search?q={query}` - Search
|
||||
- `GET /api/v1/playlists/{playlist_id}` - Playlist info
|
||||
|
||||
**Limitations:**
|
||||
- No comments support
|
||||
- Stream URLs expire after a few hours
|
||||
- Trending/popular/suggestions require backing Invidious instance
|
||||
- scheme name to build is Yattee. use generic platform build instead of specific sim/device id
|
||||
|
||||
## UI Testing with AXe
|
||||
|
||||
The project uses a Ruby/RSpec-based UI testing framework with [AXe](https://github.com/cameroncooke/AXe) for simulator automation and visual regression testing.
|
||||
|
||||
### Running UI Tests
|
||||
|
||||
```bash
|
||||
# Install dependencies (first time)
|
||||
bundle install
|
||||
|
||||
# Run all UI tests
|
||||
./bin/ui-test
|
||||
|
||||
# Skip build (faster iteration)
|
||||
./bin/ui-test --skip-build
|
||||
|
||||
# Keep simulator running after tests
|
||||
./bin/ui-test --keep-simulator
|
||||
|
||||
# Generate new baseline screenshots
|
||||
./bin/ui-test --generate-baseline
|
||||
|
||||
# Run on a different device
|
||||
./bin/ui-test --device "iPad Pro 13-inch (M5)"
|
||||
```
|
||||
|
||||
### Creating Tests for New Features
|
||||
|
||||
When implementing a new feature, create a UI test to verify it works:
|
||||
|
||||
1. **Create a new spec file** in `spec/ui/smoke/`:
|
||||
```ruby
|
||||
# spec/ui/smoke/my_feature_spec.rb
|
||||
require_relative '../spec_helper'
|
||||
|
||||
RSpec.describe 'My New Feature', :smoke do
|
||||
before(:all) do
|
||||
@udid = UITest::Simulator.boot(UITest::Config.device)
|
||||
UITest::App.build(device: UITest::Config.device, skip: UITest::Config.skip_build?)
|
||||
UITest::App.install(udid: @udid)
|
||||
UITest::App.launch(udid: @udid)
|
||||
sleep UITest::Config.app_launch_wait
|
||||
@axe = UITest::Axe.new(@udid)
|
||||
end
|
||||
|
||||
after(:all) do
|
||||
UITest::App.terminate(udid: @udid, silent: true) if @udid
|
||||
UITest::Simulator.shutdown(@udid) if @udid && !UITest::Config.keep_simulator?
|
||||
end
|
||||
|
||||
it 'displays the new feature element' do
|
||||
# Navigate to the feature if needed
|
||||
@axe.tap_label('Settings')
|
||||
sleep 1
|
||||
|
||||
# Check for expected elements
|
||||
expect(@axe).to have_text('My New Feature')
|
||||
end
|
||||
|
||||
it 'matches baseline screenshot', :visual do
|
||||
screenshot = @axe.screenshot('my-feature-screen')
|
||||
expect(screenshot).to match_baseline
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
2. **Available AXe actions:**
|
||||
```ruby
|
||||
@axe.tap_label('Button Text') # Tap by accessibility label
|
||||
@axe.tap_id('accessibilityId') # Tap by accessibility identifier
|
||||
@axe.tap_coordinates(x: 100, y: 200)
|
||||
@axe.swipe(start_x: 200, start_y: 400, end_x: 200, end_y: 100)
|
||||
@axe.gesture('scroll-down') # Presets: scroll-up, scroll-down, scroll-left, scroll-right
|
||||
@axe.type('search text') # Type text
|
||||
@axe.home_button # Press home
|
||||
@axe.screenshot('name') # Take screenshot
|
||||
```
|
||||
|
||||
3. **Available matchers:**
|
||||
```ruby
|
||||
expect(@axe).to have_element('AXUniqueId') # Check by accessibility identifier
|
||||
expect(@axe).to have_text('Visible Text') # Check by accessibility label
|
||||
expect(screenshot_path).to match_baseline # Visual comparison (2% threshold)
|
||||
```
|
||||
|
||||
4. **Run with baseline generation:**
|
||||
```bash
|
||||
./bin/ui-test --generate-baseline --keep-simulator
|
||||
```
|
||||
|
||||
5. **Inspect accessibility tree** to find element identifiers:
|
||||
```bash
|
||||
# Boot simulator and launch app first, then:
|
||||
axe describe-ui --udid <SIMULATOR_UDID>
|
||||
```
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
spec/
|
||||
├── ui/
|
||||
│ ├── spec_helper.rb # RSpec configuration
|
||||
│ ├── support/
|
||||
│ │ ├── config.rb # Test configuration
|
||||
│ │ ├── simulator.rb # Simulator management
|
||||
│ │ ├── app.rb # App build/install/launch
|
||||
│ │ ├── axe.rb # AXe CLI wrapper
|
||||
│ │ ├── axe_matchers.rb # Custom RSpec matchers
|
||||
│ │ └── screenshot_comparison.rb
|
||||
│ └── smoke/
|
||||
│ └── app_launch_spec.rb # Example test
|
||||
└── ui_snapshots/
|
||||
├── baseline/ # Reference screenshots (by device/iOS version)
|
||||
│ └── iPhone_17_Pro/
|
||||
│ └── iOS_26_2/
|
||||
│ └── app-launch-library.png
|
||||
├── current/ # Current test run screenshots
|
||||
├── diff/ # Visual diff images
|
||||
└── false_positives.yml # Mark expected differences
|
||||
```
|
||||
|
||||
### Tips
|
||||
|
||||
- Use `have_text` matcher for most checks - it's more reliable than `have_element` since iOS doesn't always expose accessibility identifiers
|
||||
- Add `sleep 1` after navigation actions to let UI settle
|
||||
- Use `--keep-simulator` during development to speed up iteration
|
||||
- Check `spec/ui_snapshots/diff/` for visual diff images when tests fail
|
||||
- Add entries to `false_positives.yml` for screenshots with expected dynamic content
|
||||
17
Extensions/Array+Next.swift
Normal file
@@ -0,0 +1,17 @@
|
||||
extension Array where Element: Equatable {
|
||||
func next(after element: Element?) -> Element? {
|
||||
if element.isNil {
|
||||
return first
|
||||
}
|
||||
|
||||
let idx = firstIndex(of: element!)
|
||||
|
||||
if idx.isNil {
|
||||
return first
|
||||
}
|
||||
|
||||
let next = index(after: idx!)
|
||||
|
||||
return self[next == endIndex ? startIndex : next]
|
||||
}
|
||||
}
|
||||
10
Extensions/CMTime+DefaultTimescale.swift
Normal file
@@ -0,0 +1,10 @@
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
|
||||
extension CMTime {
|
||||
static let defaultTimescale: CMTimeScale = 1_000_000
|
||||
|
||||
static func secondsInDefaultTimescale(_ seconds: TimeInterval) -> CMTime {
|
||||
CMTime(seconds: seconds, preferredTimescale: CMTime.defaultTimescale)
|
||||
}
|
||||
}
|
||||
15
Extensions/CaseIterable+Next.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
extension CaseIterable where Self: Equatable {
|
||||
func next(nilAtEnd: Bool = false) -> Self! {
|
||||
let all = Self.allCases
|
||||
let index = all.firstIndex(of: self)!
|
||||
let next = all.index(after: index)
|
||||
|
||||
if nilAtEnd == true {
|
||||
if next == all.endIndex {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return all[next == all.endIndex ? all.startIndex : next]
|
||||
}
|
||||
}
|
||||
17
Extensions/Color+Background.swift
Normal file
@@ -0,0 +1,17 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
#if os(macOS)
|
||||
static let background = Color(NSColor.windowBackgroundColor)
|
||||
static let secondaryBackground = Color(NSColor.underPageBackgroundColor)
|
||||
static let tertiaryBackground = Color(NSColor.controlBackgroundColor)
|
||||
#elseif os(iOS)
|
||||
static let background = Color(UIColor.systemBackground)
|
||||
static let secondaryBackground = Color(UIColor.secondarySystemBackground)
|
||||
static let tertiaryBackground = Color(UIColor.tertiarySystemBackground)
|
||||
#else
|
||||
static let background = Color.black
|
||||
static let secondaryBackground = Color.black
|
||||
static let tertiaryBackground = Color.black
|
||||
#endif
|
||||
}
|
||||
26
Extensions/Double+Format.swift
Normal file
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
extension Double {
|
||||
func formattedAsPlaybackTime() -> String? {
|
||||
guard !isZero else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let formatter = DateComponentsFormatter()
|
||||
|
||||
formatter.unitsStyle = .positional
|
||||
formatter.allowedUnits = self >= (60 * 60) ? [.hour, .minute, .second] : [.minute, .second]
|
||||
formatter.zeroFormattingBehavior = [.pad]
|
||||
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
func formattedAsRelativeTime() -> String? {
|
||||
let date = Date(timeIntervalSince1970: self)
|
||||
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .full
|
||||
|
||||
return formatter.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
}
|
||||
27
Extensions/Int+Format.swift
Normal file
@@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
|
||||
extension Int {
|
||||
func formattedAsAbbreviation() -> String {
|
||||
let num = fabs(Double(self))
|
||||
|
||||
guard num >= 1000.0 else {
|
||||
return String(self)
|
||||
}
|
||||
|
||||
let exp = Int(log10(num) / 3.0)
|
||||
let units = ["K", "M", "B", "T", "X"]
|
||||
let unit = units[exp - 1]
|
||||
|
||||
let formatter = NumberFormatter()
|
||||
|
||||
formatter.positiveSuffix = unit
|
||||
formatter.negativeSuffix = unit
|
||||
formatter.allowsFloats = true
|
||||
formatter.minimumIntegerDigits = 1
|
||||
formatter.minimumFractionDigits = 0
|
||||
formatter.maximumFractionDigits = 1
|
||||
|
||||
let roundedNum = round(10 * num / pow(1000.0, Double(exp))) / 10
|
||||
return formatter.string(from: NSNumber(value: roundedNum))!
|
||||
}
|
||||
}
|
||||
8
Extensions/NSTextField+FocusRingType.swift
Normal file
@@ -0,0 +1,8 @@
|
||||
import AppKit
|
||||
|
||||
extension NSTextField {
|
||||
override open var focusRingType: NSFocusRingType {
|
||||
get { .none }
|
||||
set {} // swiftlint:disable:this unused_setter_value
|
||||
}
|
||||
}
|
||||
10
Extensions/String+Format.swift
Normal file
@@ -0,0 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
func replacingFirstOccurrence(of target: String, with replacement: String) -> String {
|
||||
guard let range = range(of: target) else {
|
||||
return self
|
||||
}
|
||||
return replacingCharacters(in: range, with: replacement)
|
||||
}
|
||||
}
|
||||
6
Extensions/TypedContentAccessors.swift
Normal file
@@ -0,0 +1,6 @@
|
||||
import Siesta
|
||||
import SwiftyJSON
|
||||
|
||||
extension TypedContentAccessors {
|
||||
var json: JSON { typedContent(ifNone: JSON.null) }
|
||||
}
|
||||
30
Extensions/View+Borders.swift
Normal file
@@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
func borderTop(height: Double, color: Color = Color(white: 0.7, opacity: 1)) -> some View {
|
||||
verticalEdgeBorder(.top, height: height, color: color)
|
||||
}
|
||||
|
||||
func borderBottom(height: Double, color: Color = Color(white: 0.7, opacity: 1)) -> some View {
|
||||
verticalEdgeBorder(.bottom, height: height, color: color)
|
||||
}
|
||||
|
||||
func borderLeading(width: Double, color: Color = Color(white: 0.7, opacity: 1)) -> some View {
|
||||
horizontalEdgeBorder(.leading, width: width, color: color)
|
||||
}
|
||||
|
||||
func borderTrailing(width: Double, color: Color = Color(white: 0.7, opacity: 1)) -> some View {
|
||||
horizontalEdgeBorder(.trailing, width: width, color: color)
|
||||
}
|
||||
|
||||
private func verticalEdgeBorder(_ edge: Alignment, height: Double, color: Color) -> some View {
|
||||
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)
|
||||
}
|
||||
}
|
||||
12
Fixtures/ChannelPlaylist+Fixtures.swift
Normal file
@@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
extension ChannelPlaylist {
|
||||
static var fixture: ChannelPlaylist {
|
||||
ChannelPlaylist(
|
||||
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,
|
||||
videos: Video.allFixtures
|
||||
)
|
||||
}
|
||||
}
|
||||
7
Fixtures/Instance+Fixtures.swift
Normal file
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
extension Instance {
|
||||
static var fixture: Instance {
|
||||
Instance(app: .invidious, name: "Home", apiURL: "https://invidious.home.net")
|
||||
}
|
||||
}
|
||||
7
Fixtures/Playlist+Fixtures.swift
Normal file
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
extension Playlist {
|
||||
static var fixture: Playlist {
|
||||
Playlist(id: UUID().uuidString, title: "Relaxing music", visibility: .public, updated: 1)
|
||||
}
|
||||
}
|
||||
19
Fixtures/Thumbnail+Fixtures.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
extension Thumbnail {
|
||||
static func fixture(videoId: String, quality: Thumbnail.Quality = .maxres) -> Thumbnail {
|
||||
Thumbnail(url: fixtureUrl(videoId: videoId, quality: quality), quality: quality)
|
||||
}
|
||||
|
||||
static func fixturesForAllQualities(videoId: String) -> [Thumbnail] {
|
||||
Thumbnail.Quality.allCases.map { fixture(videoId: videoId, quality: $0) }
|
||||
}
|
||||
|
||||
private static var fixturesHost: String {
|
||||
"https://invidious.home.arekf.net"
|
||||
}
|
||||
|
||||
private static func fixtureUrl(videoId: String, quality: Thumbnail.Quality) -> URL {
|
||||
URL(string: "\(fixturesHost)/vi/\(videoId)/\(quality.filename).jpg")!
|
||||
}
|
||||
}
|
||||
66
Fixtures/Video+Fixtures.swift
Normal file
@@ -0,0 +1,66 @@
|
||||
import Foundation
|
||||
|
||||
extension Video {
|
||||
static var fixture: Video {
|
||||
let id = "D2sxamzaHkM"
|
||||
let thumbnailURL = "https://yt3.ggpht.com/ytc/AKedOLR-pT_JEsz_hcaA4Gjx8DHcqJ8mS42aTRqcVy6P7w=s88-c-k-c0x00ffffff-no-rj-mo"
|
||||
|
||||
return Video(
|
||||
videoID: UUID().uuidString,
|
||||
title: "Relaxing Piano Music that will make you feel amazingly good",
|
||||
author: "Fancy Videotuber",
|
||||
length: 582,
|
||||
published: "7 years ago",
|
||||
views: 21534,
|
||||
description: "Some relaxing live piano music",
|
||||
genre: "Music",
|
||||
channel: Channel(
|
||||
id: "AbCdEFgHI",
|
||||
name: "The Channel",
|
||||
thumbnailURL: URL(string: thumbnailURL)!,
|
||||
subscriptionsCount: 2300,
|
||||
videos: []
|
||||
),
|
||||
thumbnails: Thumbnail.fixturesForAllQualities(videoId: id),
|
||||
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"]
|
||||
)
|
||||
}
|
||||
|
||||
static var fixtureLiveWithoutPublishedOrViews: Video {
|
||||
var video = fixture
|
||||
|
||||
video.title = "\(video.title) \(video.title) \(video.title) \(video.title) \(video.title)"
|
||||
video.published = "0 seconds ago"
|
||||
video.views = 0
|
||||
video.live = true
|
||||
|
||||
return video
|
||||
}
|
||||
|
||||
static var fixtureUpcomingWithoutPublishedOrViews: Video {
|
||||
var video = fixtureLiveWithoutPublishedOrViews
|
||||
|
||||
video.live = false
|
||||
video.upcoming = true
|
||||
|
||||
return video
|
||||
}
|
||||
|
||||
static var allFixtures: [Video] {
|
||||
[fixture, fixtureLiveWithoutPublishedOrViews, fixtureUpcomingWithoutPublishedOrViews]
|
||||
}
|
||||
|
||||
static func fixtures(_ count: Int) -> [Video] {
|
||||
var result = [Video]()
|
||||
while result.count < count {
|
||||
result.append(allFixtures.shuffled().first!)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
52
Fixtures/View+Fixtures.swift
Normal file
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct FixtureEnvironmentObjectsModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.environmentObject(AccountsModel())
|
||||
.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) }
|
||||
player.history = player.queue
|
||||
|
||||
return player
|
||||
}
|
||||
|
||||
private var subscriptions: SubscriptionsModel {
|
||||
let subscriptions = SubscriptionsModel()
|
||||
|
||||
subscriptions.channels = Video.allFixtures.map { $0.channel }
|
||||
|
||||
return subscriptions
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func injectFixtureEnvironmentObjects() -> some View {
|
||||
modifier(FixtureEnvironmentObjectsModifier())
|
||||
}
|
||||
}
|
||||
20
Gemfile
@@ -1,20 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
source "https://rubygems.org"
|
||||
|
||||
# Fastlane for build automation and distribution
|
||||
gem 'fastlane', '~> 2.225'
|
||||
|
||||
# Load environment variables from .env files
|
||||
# Note: fastlane requires dotenv < 3.0, so we use 2.x
|
||||
gem 'dotenv', '~> 2.8'
|
||||
|
||||
group :test do
|
||||
# RSpec for UI testing framework
|
||||
gem 'rspec', '~> 3.13'
|
||||
# Retry flaky UI tests automatically
|
||||
gem 'rspec-retry', '~> 0.6'
|
||||
# Code linting
|
||||
gem 'rubocop', '~> 1.69', require: false
|
||||
gem 'rubocop-rspec', '~> 3.3', require: false
|
||||
end
|
||||
284
Gemfile.lock
@@ -1,284 +0,0 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.8)
|
||||
abbrev (0.1.2)
|
||||
addressable (2.8.8)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
artifactory (3.0.17)
|
||||
ast (2.4.3)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1198.0)
|
||||
aws-sdk-core (3.240.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.118.0)
|
||||
aws-sdk-core (~> 3, >= 3.239.1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.209.0)
|
||||
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.2.0)
|
||||
bigdecimal (4.0.1)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
csv (3.3.5)
|
||||
declarative (0.0.20)
|
||||
diff-lcs (1.6.2)
|
||||
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.230.0)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
abbrev (~> 0.1.2)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.0)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
base64 (~> 0.2.0)
|
||||
bundler (>= 1.12.0, < 3.0.0)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
csv (~> 3.3)
|
||||
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)
|
||||
logger (>= 1.6, < 2.0)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
mutex_m (~> 0.3.0)
|
||||
naturally (~> 2.2)
|
||||
nkf (~> 0.2.0)
|
||||
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.18.0)
|
||||
jwt (2.10.2)
|
||||
base64
|
||||
language_server-protocol (3.17.0.5)
|
||||
lint_roller (1.1.0)
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.18.0)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.3.0)
|
||||
nkf (0.2.0)
|
||||
optparse (0.8.1)
|
||||
os (1.1.4)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.10.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
plist (3.7.2)
|
||||
prism (1.7.0)
|
||||
public_suffix (7.0.0)
|
||||
racc (1.8.1)
|
||||
rainbow (3.1.1)
|
||||
rake (13.3.1)
|
||||
regexp_parser (2.11.3)
|
||||
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)
|
||||
rspec (3.13.2)
|
||||
rspec-core (~> 3.13.0)
|
||||
rspec-expectations (~> 3.13.0)
|
||||
rspec-mocks (~> 3.13.0)
|
||||
rspec-core (3.13.6)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.5)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-mocks (3.13.7)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-retry (0.6.2)
|
||||
rspec-core (> 3.3)
|
||||
rspec-support (3.13.6)
|
||||
rubocop (1.82.1)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.48.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.48.0)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.4)
|
||||
rubocop-rspec (3.8.0)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (~> 1.81)
|
||||
ruby-progressbar (1.13.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-24
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
dotenv (~> 2.8)
|
||||
fastlane (~> 2.225)
|
||||
rspec (~> 3.13)
|
||||
rspec-retry (~> 0.6)
|
||||
rubocop (~> 1.69)
|
||||
rubocop-rspec (~> 3.3)
|
||||
|
||||
BUNDLED WITH
|
||||
2.6.3
|
||||
57
Model/Accounts/Account.swift
Normal file
@@ -0,0 +1,57 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
struct Account: Defaults.Serializable, Hashable, Identifiable {
|
||||
static var bridge = AccountsBridge()
|
||||
|
||||
let id: String
|
||||
let instanceID: String
|
||||
var name: String?
|
||||
let url: String
|
||||
let username: String
|
||||
let password: String?
|
||||
var token: String?
|
||||
let anonymous: Bool
|
||||
|
||||
init(
|
||||
id: String? = nil,
|
||||
instanceID: String? = nil,
|
||||
name: String? = nil,
|
||||
url: String? = nil,
|
||||
username: String? = nil,
|
||||
password: String? = nil,
|
||||
token: String? = nil,
|
||||
anonymous: Bool = false
|
||||
) {
|
||||
self.anonymous = anonymous
|
||||
|
||||
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 ?? ""
|
||||
}
|
||||
|
||||
var instance: Instance! {
|
||||
Defaults[.instances].first { $0.id == instanceID }
|
||||
}
|
||||
|
||||
var shortUsername: String {
|
||||
guard username.count > 10 else {
|
||||
return username
|
||||
}
|
||||
|
||||
let index = username.index(username.startIndex, offsetBy: 11)
|
||||
return String(username[..<index])
|
||||
}
|
||||
|
||||
var description: String {
|
||||
(name != nil && name!.isEmpty) ? shortUsername : name!
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(username)
|
||||
}
|
||||
}
|
||||
158
Model/Accounts/AccountValidator.swift
Normal file
@@ -0,0 +1,158 @@
|
||||
import Foundation
|
||||
import Siesta
|
||||
import SwiftUI
|
||||
|
||||
final class AccountValidator: Service {
|
||||
let app: Binding<VideosApp>
|
||||
let url: String
|
||||
let account: Account!
|
||||
|
||||
var formObjectID: Binding<String>
|
||||
var isValid: Binding<Bool>
|
||||
var isValidated: Binding<Bool>
|
||||
var isValidating: Binding<Bool>
|
||||
var error: Binding<String?>?
|
||||
|
||||
init(
|
||||
app: Binding<VideosApp>,
|
||||
url: String,
|
||||
account: Account? = nil,
|
||||
id: Binding<String>,
|
||||
isValid: Binding<Bool>,
|
||||
isValidated: Binding<Bool>,
|
||||
isValidating: Binding<Bool>,
|
||||
error: Binding<String?>? = nil
|
||||
) {
|
||||
self.app = app
|
||||
self.url = url
|
||||
self.account = account
|
||||
formObjectID = id
|
||||
self.isValid = isValid
|
||||
self.isValidated = isValidated
|
||||
self.isValidating = isValidating
|
||||
self.error = error
|
||||
|
||||
super.init(baseURL: url)
|
||||
configure()
|
||||
}
|
||||
|
||||
func configure() {
|
||||
configure {
|
||||
$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 validateInstance() {
|
||||
reset()
|
||||
|
||||
neverGonnaGiveYouUp
|
||||
.load()
|
||||
.onSuccess { response in
|
||||
guard self.url == self.formObjectID.wrappedValue else {
|
||||
return
|
||||
}
|
||||
|
||||
let json = response.json.dictionaryValue
|
||||
let author = self.app.wrappedValue == .invidious ? json["author"] : json["uploader"]
|
||||
|
||||
if author == "Rick Astley" {
|
||||
self.isValid.wrappedValue = true
|
||||
self.error?.wrappedValue = nil
|
||||
} else {
|
||||
self.isValid.wrappedValue = false
|
||||
}
|
||||
}
|
||||
.onFailure { error in
|
||||
guard self.url == self.formObjectID.wrappedValue else {
|
||||
return
|
||||
}
|
||||
|
||||
self.isValid.wrappedValue = false
|
||||
self.error?.wrappedValue = error.userMessage
|
||||
}
|
||||
.onCompletion { _ in
|
||||
self.isValidated.wrappedValue = true
|
||||
self.isValidating.wrappedValue = false
|
||||
}
|
||||
}
|
||||
|
||||
func validateAccount() {
|
||||
reset()
|
||||
|
||||
accountRequest
|
||||
.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
|
||||
}
|
||||
}
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
||||
var accountRequest: Request {
|
||||
switch app.wrappedValue {
|
||||
case .invidious:
|
||||
return feed.load()
|
||||
case .piped:
|
||||
return login.request(.post, json: ["username": account.username, "password": account.password])
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
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"
|
||||
}
|
||||
|
||||
var neverGonnaGiveYouUp: Resource {
|
||||
resource("\(videoResourceBasePath)/dQw4w9WgXcQ")
|
||||
}
|
||||
}
|
||||
39
Model/Accounts/AccountsBridge.swift
Normal file
@@ -0,0 +1,39 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
struct AccountsBridge: Defaults.Bridge {
|
||||
typealias Value = Account
|
||||
typealias Serializable = [String: String]
|
||||
|
||||
func serialize(_ value: Value?) -> Serializable? {
|
||||
guard let value = value else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return [
|
||||
"id": value.id,
|
||||
"instanceID": value.instanceID,
|
||||
"name": value.name ?? "",
|
||||
"apiURL": value.url,
|
||||
"username": value.username,
|
||||
"password": value.password ?? ""
|
||||
]
|
||||
}
|
||||
|
||||
func deserialize(_ object: Serializable?) -> Value? {
|
||||
guard
|
||||
let object = object,
|
||||
let id = object["id"],
|
||||
let instanceID = object["instanceID"],
|
||||
let url = object["apiURL"],
|
||||
let username = object["username"]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let name = object["name"] ?? ""
|
||||
let password = object["password"]
|
||||
|
||||
return Account(id: id, instanceID: instanceID, name: name, url: url, username: username, password: password)
|
||||
}
|
||||
}
|
||||
95
Model/Accounts/AccountsModel.swift
Normal file
@@ -0,0 +1,95 @@
|
||||
import Combine
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
final class AccountsModel: ObservableObject {
|
||||
@Published private(set) var current: Account!
|
||||
|
||||
@Published private var invidious = InvidiousAPI()
|
||||
@Published private var piped = PipedAPI()
|
||||
|
||||
private var cancellables = [AnyCancellable]()
|
||||
|
||||
var all: [Account] {
|
||||
Defaults[.accounts]
|
||||
}
|
||||
|
||||
var lastUsed: Account? {
|
||||
guard let id = Defaults[.lastAccountID] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return AccountsModel.find(id)
|
||||
}
|
||||
|
||||
var app: VideosApp {
|
||||
current?.instance?.app ?? .invidious
|
||||
}
|
||||
|
||||
var api: VideosAPI {
|
||||
app == .piped ? piped : invidious
|
||||
}
|
||||
|
||||
var isEmpty: Bool {
|
||||
current.isNil
|
||||
}
|
||||
|
||||
var signedIn: Bool {
|
||||
!isEmpty && !current.anonymous && api.signedIn
|
||||
}
|
||||
|
||||
init() {
|
||||
cancellables.append(
|
||||
invidious.objectWillChange.sink { [weak self] _ in self?.objectWillChange.send() }
|
||||
)
|
||||
|
||||
cancellables.append(
|
||||
piped.objectWillChange.sink { [weak self] _ in self?.objectWillChange.send() }
|
||||
)
|
||||
}
|
||||
|
||||
func setCurrent(_ account: Account! = nil) {
|
||||
guard account != current else {
|
||||
return
|
||||
}
|
||||
|
||||
current = account
|
||||
|
||||
guard !account.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
switch account.instance.app {
|
||||
case .invidious:
|
||||
invidious.setAccount(account)
|
||||
case .piped:
|
||||
piped.setAccount(account)
|
||||
}
|
||||
|
||||
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, 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)
|
||||
|
||||
return account
|
||||
}
|
||||
|
||||
static func remove(_ account: Account) {
|
||||
if let accountIndex = Defaults[.accounts].firstIndex(where: { $0.id == account.id }) {
|
||||
Defaults[.accounts].remove(at: accountIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
61
Model/Accounts/Instance.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
struct Instance: Defaults.Serializable, Hashable, Identifiable {
|
||||
static var bridge = InstancesBridge()
|
||||
|
||||
let app: VideosApp
|
||||
let id: String
|
||||
let name: String
|
||||
let apiURL: String
|
||||
var frontendURL: String?
|
||||
|
||||
init(app: VideosApp, id: String? = nil, name: String, apiURL: String, frontendURL: String? = nil) {
|
||||
self.app = app
|
||||
self.id = id ?? UUID().uuidString
|
||||
self.name = name
|
||||
self.apiURL = apiURL
|
||||
self.frontendURL = frontendURL
|
||||
}
|
||||
|
||||
var anonymous: VideosAPI {
|
||||
switch app {
|
||||
case .invidious:
|
||||
return InvidiousAPI(account: anonymousAccount)
|
||||
case .piped:
|
||||
return PipedAPI(account: anonymousAccount)
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
"\(app.name) - \(shortDescription)"
|
||||
}
|
||||
|
||||
var longDescription: String {
|
||||
name.isEmpty ? "\(app.name) - \(apiURL)" : "\(app.name) - \(name) (\(apiURL))"
|
||||
}
|
||||
|
||||
var shortDescription: String {
|
||||
name.isEmpty ? apiURL : name
|
||||
}
|
||||
|
||||
var anonymousAccount: Account {
|
||||
Account(instanceID: id, name: "Anonymous", url: apiURL, anonymous: true)
|
||||
}
|
||||
|
||||
var urlComponents: URLComponents {
|
||||
URLComponents(string: apiURL)!
|
||||
}
|
||||
|
||||
var frontendHost: String? {
|
||||
guard let url = app == .invidious ? apiURL : frontendURL else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return URLComponents(string: url)?.host
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(apiURL)
|
||||
}
|
||||
}
|
||||
37
Model/Accounts/InstancesBridge.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
struct InstancesBridge: Defaults.Bridge {
|
||||
typealias Value = Instance
|
||||
typealias Serializable = [String: String]
|
||||
|
||||
func serialize(_ value: Value?) -> Serializable? {
|
||||
guard let value = value else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return [
|
||||
"app": value.app.rawValue,
|
||||
"id": value.id,
|
||||
"name": value.name,
|
||||
"apiURL": value.apiURL,
|
||||
"frontendURL": value.frontendURL ?? ""
|
||||
]
|
||||
}
|
||||
|
||||
func deserialize(_ object: Serializable?) -> Value? {
|
||||
guard
|
||||
let object = object,
|
||||
let app = VideosApp(rawValue: object["app"] ?? ""),
|
||||
let id = object["id"],
|
||||
let apiURL = object["apiURL"]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let name = object["name"] ?? ""
|
||||
|
||||
let frontendURL: String? = object["frontendURL"]!.isEmpty ? nil : object["frontendURL"]
|
||||
return Instance(app: app, id: id, name: name, apiURL: apiURL, frontendURL: frontendURL)
|
||||
}
|
||||
}
|
||||
62
Model/Accounts/InstancesModel.swift
Normal file
@@ -0,0 +1,62 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
final class InstancesModel: ObservableObject {
|
||||
static var all: [Instance] {
|
||||
Defaults[.instances]
|
||||
}
|
||||
|
||||
var lastUsed: Instance? {
|
||||
guard let id = Defaults[.lastInstanceID] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return InstancesModel.find(id)
|
||||
}
|
||||
|
||||
static func find(_ id: Instance.ID?) -> Instance? {
|
||||
guard id != nil else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Defaults[.instances].first { $0.id == id }
|
||||
}
|
||||
|
||||
static func accounts(_ id: Instance.ID?) -> [Account] {
|
||||
Defaults[.accounts].filter { $0.instanceID == id }
|
||||
}
|
||||
|
||||
static func add(app: VideosApp, name: String, url: String) -> Instance {
|
||||
let instance = Instance(
|
||||
app: app, id: UUID().uuidString, name: name, apiURL: standardizedURL(url)
|
||||
)
|
||||
Defaults[.instances].append(instance)
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
Defaults[.instances][index] = instance
|
||||
}
|
||||
}
|
||||
|
||||
static func remove(_ instance: Instance) {
|
||||
let accounts = InstancesModel.accounts(instance.id)
|
||||
if let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) {
|
||||
Defaults[.instances].remove(at: index)
|
||||
accounts.forEach { AccountsModel.remove($0) }
|
||||
}
|
||||
}
|
||||
|
||||
static func standardizedURL(_ url: String) -> String {
|
||||
if url.last == "/" {
|
||||
return String(url.dropLast())
|
||||
} else {
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
407
Model/Applications/InvidiousAPI.swift
Normal file
@@ -0,0 +1,407 @@
|
||||
import AVKit
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Siesta
|
||||
import SwiftyJSON
|
||||
|
||||
final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
static let basePath = "/api/v1"
|
||||
|
||||
@Published var account: Account!
|
||||
|
||||
@Published var validInstance = true
|
||||
@Published var signedIn = false
|
||||
|
||||
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 = false
|
||||
signedIn = false
|
||||
|
||||
configure()
|
||||
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() {
|
||||
configure {
|
||||
if !self.account.username.isEmpty {
|
||||
$0.headers["Cookie"] = self.cookieHeader
|
||||
}
|
||||
$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(InvidiousAPI.extractVideo)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("trending"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
||||
content.json.arrayValue.map(InvidiousAPI.extractVideo)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("search"), requestMethods: [.get]) { (content: Entity<JSON>) -> [ContentItem] in
|
||||
content.json.arrayValue.map {
|
||||
let type = $0.dictionaryValue["type"]?.stringValue
|
||||
|
||||
if type == "channel" {
|
||||
return ContentItem(channel: InvidiousAPI.extractChannel(from: $0))
|
||||
} else if type == "playlist" {
|
||||
return ContentItem(playlist: InvidiousAPI.extractChannelPlaylist(from: $0))
|
||||
}
|
||||
return ContentItem(video: InvidiousAPI.extractVideo(from: $0))
|
||||
}
|
||||
}
|
||||
|
||||
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(Playlist.init)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("auth/playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Playlist in
|
||||
Playlist(content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.post, .patch]) { (content: Entity<Data>) -> Playlist in
|
||||
// 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
|
||||
if let feedVideos = content.json.dictionaryValue["videos"] {
|
||||
return feedVideos.arrayValue.map(InvidiousAPI.extractVideo)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("auth/subscriptions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Channel] in
|
||||
content.json.arrayValue.map(InvidiousAPI.extractChannel)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("channels/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Channel in
|
||||
InvidiousAPI.extractChannel(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("channels/*/latest"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
||||
content.json.arrayValue.map(InvidiousAPI.extractVideo)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPlaylist in
|
||||
InvidiousAPI.extractChannelPlaylist(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("videos/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Video in
|
||||
InvidiousAPI.extractVideo(from: content.json)
|
||||
}
|
||||
}
|
||||
|
||||
private func pathPattern(_ path: String) -> String {
|
||||
"**\(InvidiousAPI.basePath)/\(path)"
|
||||
}
|
||||
|
||||
private func basePathAppending(_ path: String) -> String {
|
||||
"\(InvidiousAPI.basePath)/\(path)"
|
||||
}
|
||||
|
||||
private var cookieHeader: String {
|
||||
"SID=\(account.username)"
|
||||
}
|
||||
|
||||
var popular: Resource? {
|
||||
resource(baseURL: account.url, path: "\(InvidiousAPI.basePath)/popular")
|
||||
}
|
||||
|
||||
func trending(country: Country, category: TrendingCategory?) -> Resource {
|
||||
resource(baseURL: account.url, path: "\(InvidiousAPI.basePath)/trending")
|
||||
.withParam("type", category?.name)
|
||||
.withParam("region", country.rawValue)
|
||||
}
|
||||
|
||||
var home: Resource? {
|
||||
resource(baseURL: account.url, path: "/feed/subscriptions")
|
||||
}
|
||||
|
||||
var feed: Resource? {
|
||||
resource(baseURL: account.url, path: "\(InvidiousAPI.basePath)/auth/feed")
|
||||
}
|
||||
|
||||
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) -> Resource {
|
||||
resource(baseURL: account.url, path: basePathAppending("channels/\(id)"))
|
||||
}
|
||||
|
||||
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 channelPlaylist(_ id: String) -> Resource? {
|
||||
resource(baseURL: account.url, path: basePathAppending("playlists/\(id)"))
|
||||
}
|
||||
|
||||
func search(_ query: SearchQuery) -> Resource {
|
||||
var resource = resource(baseURL: account.url, path: basePathAppending("search"))
|
||||
.withParam("q", searchQuery(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)
|
||||
}
|
||||
|
||||
return resource
|
||||
}
|
||||
|
||||
func searchSuggestions(query: String) -> Resource {
|
||||
resource(baseURL: account.url, path: basePathAppending("search/suggestions"))
|
||||
.withParam("q", query.lowercased())
|
||||
}
|
||||
|
||||
private func searchQuery(_ query: String) -> String {
|
||||
var searchQuery = query
|
||||
|
||||
let url = URLComponents(string: query)
|
||||
|
||||
if url != nil,
|
||||
url!.host == "youtu.be"
|
||||
{
|
||||
searchQuery = url!.path.replacingOccurrences(of: "/", with: "")
|
||||
}
|
||||
|
||||
let queryItem = url?.queryItems?.first { item in item.name == "v" }
|
||||
if let id = queryItem?.value {
|
||||
searchQuery = id
|
||||
}
|
||||
|
||||
return searchQuery
|
||||
}
|
||||
|
||||
static func proxiedAsset(instance: Instance, asset: AVURLAsset) -> AVURLAsset? {
|
||||
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
|
||||
|
||||
guard let url = urlComponents.url else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return AVURLAsset(url: url)
|
||||
}
|
||||
|
||||
static func extractVideo(from json: JSON) -> Video {
|
||||
let indexID: String?
|
||||
var id: Video.ID
|
||||
var publishedAt: Date?
|
||||
|
||||
if let publishedInterval = json["published"].double {
|
||||
publishedAt = Date(timeIntervalSince1970: publishedInterval)
|
||||
}
|
||||
|
||||
let videoID = json["videoId"].stringValue
|
||||
|
||||
if let index = json["indexId"].string {
|
||||
indexID = index
|
||||
id = videoID + index
|
||||
} else {
|
||||
indexID = nil
|
||||
id = videoID
|
||||
}
|
||||
|
||||
return Video(
|
||||
id: id,
|
||||
videoID: videoID,
|
||||
title: json["title"].stringValue,
|
||||
author: json["author"].stringValue,
|
||||
length: json["lengthSeconds"].doubleValue,
|
||||
published: json["publishedText"].stringValue,
|
||||
views: json["viewCount"].intValue,
|
||||
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,
|
||||
publishedAt: publishedAt,
|
||||
likes: json["likeCount"].int,
|
||||
dislikes: json["dislikeCount"].int,
|
||||
keywords: json["keywords"].arrayValue.map { $0.stringValue },
|
||||
streams: extractStreams(from: json),
|
||||
related: extractRelated(from: json)
|
||||
)
|
||||
}
|
||||
|
||||
static func extractChannel(from json: JSON) -> Channel {
|
||||
let thumbnailURL = "https:\(json["authorThumbnails"].arrayValue.first?.dictionaryValue["url"]?.stringValue ?? "")"
|
||||
|
||||
return Channel(
|
||||
id: json["authorId"].stringValue,
|
||||
name: json["author"].stringValue,
|
||||
thumbnailURL: URL(string: thumbnailURL),
|
||||
subscriptionsCount: json["subCount"].int,
|
||||
subscriptionsText: json["subCountText"].string,
|
||||
videos: json.dictionaryValue["latestVideos"]?.arrayValue.map(InvidiousAPI.extractVideo) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
static func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist {
|
||||
let details = json.dictionaryValue
|
||||
return ChannelPlaylist(
|
||||
id: details["playlistId"]!.stringValue,
|
||||
title: details["title"]!.stringValue,
|
||||
thumbnailURL: details["playlistThumbnail"]?.url,
|
||||
channel: extractChannel(from: json),
|
||||
videos: details["videos"]?.arrayValue.compactMap(InvidiousAPI.extractVideo) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
private static func extractThumbnails(from details: JSON) -> [Thumbnail] {
|
||||
details["videoThumbnails"].arrayValue.map { json in
|
||||
Thumbnail(url: json["url"].url!, quality: .init(rawValue: json["quality"].string!)!)
|
||||
}
|
||||
}
|
||||
|
||||
private static func extractStreams(from json: JSON) -> [Stream] {
|
||||
extractFormatStreams(from: json["formatStreams"].arrayValue) +
|
||||
extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue)
|
||||
}
|
||||
|
||||
private static 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: $0["encoding"].stringValue
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func extractAdaptiveFormats(from streams: [JSON]) -> [Stream] {
|
||||
let audioAssetURL = streams.first { $0["type"].stringValue.starts(with: "audio/mp4") }
|
||||
guard audioAssetURL != nil else {
|
||||
return []
|
||||
}
|
||||
|
||||
let videoAssetsURLs = streams.filter { $0["type"].stringValue.starts(with: "video/mp4") && $0["encoding"].stringValue == "h264" }
|
||||
|
||||
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: $0["encoding"].stringValue
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func extractRelated(from content: JSON) -> [Video] {
|
||||
content
|
||||
.dictionaryValue["recommendedVideos"]?
|
||||
.arrayValue
|
||||
.compactMap(extractVideo(from:)) ?? []
|
||||
}
|
||||
}
|
||||
398
Model/Applications/PipedAPI.swift
Normal file
@@ -0,0 +1,398 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Siesta
|
||||
import SwiftyJSON
|
||||
|
||||
final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
static var authorizedEndpoints = ["subscriptions", "subscribe", "unsubscribe"]
|
||||
|
||||
@Published var account: Account!
|
||||
|
||||
var anonymousAccount: Account {
|
||||
.init(instanceID: account.instance.id, name: "Anonymous", url: account.instance.apiURL)
|
||||
}
|
||||
|
||||
init(account: Account? = nil) {
|
||||
super.init()
|
||||
|
||||
guard account != nil else {
|
||||
return
|
||||
}
|
||||
|
||||
setAccount(account!)
|
||||
}
|
||||
|
||||
func setAccount(_ account: Account) {
|
||||
self.account = account
|
||||
|
||||
configure()
|
||||
}
|
||||
|
||||
func configure() {
|
||||
invalidateConfiguration()
|
||||
|
||||
configure {
|
||||
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
|
||||
}
|
||||
|
||||
configure(whenURLMatches: { url in self.needsAuthorization(url) }) {
|
||||
$0.headers["Authorization"] = self.account.token
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("channel/*")) { (content: Entity<JSON>) -> Channel? in
|
||||
PipedAPI.extractChannel(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("playlists/*")) { (content: Entity<JSON>) -> ChannelPlaylist? in
|
||||
PipedAPI.extractChannelPlaylist(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("streams/*")) { (content: Entity<JSON>) -> Video? in
|
||||
PipedAPI.extractVideo(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("trending")) { (content: Entity<JSON>) -> [Video] in
|
||||
PipedAPI.extractVideos(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("search")) { (content: Entity<JSON>) -> [ContentItem] in
|
||||
PipedAPI.extractContentItems(from: content.json.dictionaryValue["items"]!)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("suggestions")) { (content: Entity<JSON>) -> [String] in
|
||||
content.json.arrayValue.map(String.init)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("subscriptions")) { (content: Entity<JSON>) -> [Channel] in
|
||||
content.json.arrayValue.map { PipedAPI.extractChannel(from: $0)! }
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("feed")) { (content: Entity<JSON>) -> [Video] in
|
||||
content.json.arrayValue.map { PipedAPI.extractVideo(from: $0)! }
|
||||
}
|
||||
|
||||
if account.token.isNil {
|
||||
updateToken()
|
||||
}
|
||||
}
|
||||
|
||||
func needsAuthorization(_ url: URL) -> Bool {
|
||||
PipedAPI.authorizedEndpoints.contains { url.absoluteString.contains($0) }
|
||||
}
|
||||
|
||||
@discardableResult func updateToken() -> Request {
|
||||
account.token = nil
|
||||
return login.request(
|
||||
.post,
|
||||
json: ["username": account.username, "password": account.password]
|
||||
)
|
||||
.onSuccess { response in
|
||||
self.account.token = response.json.dictionaryValue["token"]?.string ?? ""
|
||||
self.configure()
|
||||
}
|
||||
}
|
||||
|
||||
var login: Resource {
|
||||
resource(baseURL: account.url, path: "login")
|
||||
}
|
||||
|
||||
func channel(_ id: String) -> Resource {
|
||||
resource(baseURL: account.url, path: "channel/\(id)")
|
||||
}
|
||||
|
||||
func channelVideos(_ id: String) -> Resource {
|
||||
channel(id)
|
||||
}
|
||||
|
||||
func channelPlaylist(_ id: String) -> Resource? {
|
||||
resource(baseURL: account.url, path: "playlists/\(id)")
|
||||
}
|
||||
|
||||
func trending(country: Country, category _: TrendingCategory? = nil) -> Resource {
|
||||
resource(baseURL: account.instance.apiURL, path: "trending")
|
||||
.withParam("region", country.rawValue)
|
||||
}
|
||||
|
||||
func search(_ query: SearchQuery) -> Resource {
|
||||
resource(baseURL: account.instance.apiURL, path: "search")
|
||||
.withParam("q", query.query)
|
||||
.withParam("filter", "")
|
||||
}
|
||||
|
||||
func searchSuggestions(query: String) -> Resource {
|
||||
resource(baseURL: account.instance.apiURL, path: "suggestions")
|
||||
.withParam("query", query.lowercased())
|
||||
}
|
||||
|
||||
func video(_ id: Video.ID) -> Resource {
|
||||
resource(baseURL: account.instance.apiURL, path: "streams/\(id)")
|
||||
}
|
||||
|
||||
var signedIn: Bool {
|
||||
!account.anonymous && !(account.token?.isEmpty ?? true)
|
||||
}
|
||||
|
||||
var subscriptions: Resource? {
|
||||
resource(baseURL: account.instance.apiURL, path: "subscriptions")
|
||||
}
|
||||
|
||||
var feed: Resource? {
|
||||
resource(baseURL: account.instance.apiURL, path: "feed")
|
||||
.withParam("authToken", account.token)
|
||||
}
|
||||
|
||||
var home: Resource? { nil }
|
||||
var popular: Resource? { nil }
|
||||
var playlists: Resource? { nil }
|
||||
|
||||
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
|
||||
resource(baseURL: account.instance.apiURL, path: "subscribe")
|
||||
.request(.post, json: ["channelId": channelID])
|
||||
.onCompletion { _ in onCompletion() }
|
||||
}
|
||||
|
||||
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
|
||||
resource(baseURL: account.instance.apiURL, path: "unsubscribe")
|
||||
.request(.post, json: ["channelId": channelID])
|
||||
.onCompletion { _ in onCompletion() }
|
||||
}
|
||||
|
||||
func playlist(_: String) -> Resource? { nil }
|
||||
func playlistVideo(_: String, _: String) -> Resource? { nil }
|
||||
func playlistVideos(_: String) -> Resource? { nil }
|
||||
|
||||
private func pathPattern(_ path: String) -> String {
|
||||
"**\(path)"
|
||||
}
|
||||
|
||||
private static func extractContentItem(from content: JSON) -> ContentItem? {
|
||||
let details = content.dictionaryValue
|
||||
let url: String! = details["url"]?.string
|
||||
|
||||
let contentType: ContentItem.ContentType
|
||||
|
||||
if !url.isNil {
|
||||
if url.contains("/playlist") {
|
||||
contentType = .playlist
|
||||
} else if url.contains("/channel") {
|
||||
contentType = .channel
|
||||
} else {
|
||||
contentType = .video
|
||||
}
|
||||
} else {
|
||||
contentType = .video
|
||||
}
|
||||
|
||||
switch contentType {
|
||||
case .video:
|
||||
if let video = PipedAPI.extractVideo(from: content) {
|
||||
return ContentItem(video: video)
|
||||
}
|
||||
|
||||
case .playlist:
|
||||
if let playlist = PipedAPI.extractChannelPlaylist(from: content) {
|
||||
return ContentItem(playlist: playlist)
|
||||
}
|
||||
|
||||
case .channel:
|
||||
if let channel = PipedAPI.extractChannel(from: content) {
|
||||
return ContentItem(channel: channel)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func extractContentItems(from content: JSON) -> [ContentItem] {
|
||||
content.arrayValue.compactMap { PipedAPI.extractContentItem(from: $0) }
|
||||
}
|
||||
|
||||
private static func extractChannel(from content: JSON) -> Channel? {
|
||||
let attributes = content.dictionaryValue
|
||||
guard let id = attributes["id"]?.stringValue ??
|
||||
(attributes["url"] ?? attributes["uploaderUrl"])?.stringValue.components(separatedBy: "/").last
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let subscriptionsCount = attributes["subscriberCount"]?.intValue ?? attributes["subscribers"]?.intValue
|
||||
|
||||
var videos = [Video]()
|
||||
if let relatedStreams = attributes["relatedStreams"] {
|
||||
videos = PipedAPI.extractVideos(from: relatedStreams)
|
||||
}
|
||||
|
||||
return Channel(
|
||||
id: id,
|
||||
name: attributes["name"]!.stringValue,
|
||||
thumbnailURL: attributes["thumbnail"]?.url,
|
||||
subscriptionsCount: subscriptionsCount,
|
||||
videos: videos
|
||||
)
|
||||
}
|
||||
|
||||
static func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist? {
|
||||
let details = json.dictionaryValue
|
||||
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 = PipedAPI.extractVideos(from: relatedStreams)
|
||||
}
|
||||
return ChannelPlaylist(
|
||||
id: id,
|
||||
title: details["name"]!.stringValue,
|
||||
thumbnailURL: thumbnailURL,
|
||||
channel: extractChannel(from: json)!,
|
||||
videos: videos,
|
||||
videosCount: details["videos"]?.int
|
||||
)
|
||||
}
|
||||
|
||||
private static func extractVideo(from content: JSON) -> Video? {
|
||||
let details = content.dictionaryValue
|
||||
let url = details["url"]?.string
|
||||
|
||||
if !url.isNil {
|
||||
guard url!.contains("/watch") else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
let channelId = details["uploaderUrl"]!.stringValue.components(separatedBy: "/").last!
|
||||
|
||||
let thumbnails: [Thumbnail] = Thumbnail.Quality.allCases.compactMap {
|
||||
if let url = PipedAPI.buildThumbnailURL(from: content, quality: $0) {
|
||||
return Thumbnail(url: url, quality: $0)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
let author = details["uploaderName"]?.stringValue ?? details["uploader"]!.stringValue
|
||||
let published = (details["uploadedDate"] ?? details["uploadDate"])?.stringValue ??
|
||||
(details["uploaded"]!.double! / 1000).formattedAsRelativeTime()!
|
||||
|
||||
return Video(
|
||||
videoID: PipedAPI.extractID(from: content),
|
||||
title: details["title"]!.stringValue,
|
||||
author: author,
|
||||
length: details["duration"]!.doubleValue,
|
||||
published: published,
|
||||
views: details["views"]!.intValue,
|
||||
description: PipedAPI.extractDescription(from: content),
|
||||
channel: Channel(id: channelId, name: author),
|
||||
thumbnails: thumbnails,
|
||||
likes: details["likes"]?.int,
|
||||
dislikes: details["dislikes"]?.int,
|
||||
streams: extractStreams(from: content),
|
||||
related: extractRelated(from: content)
|
||||
)
|
||||
}
|
||||
|
||||
private static func extractID(from content: JSON) -> Video.ID {
|
||||
content.dictionaryValue["url"]?.stringValue.components(separatedBy: "?v=").last ??
|
||||
extractThumbnailURL(from: content)!.relativeString.components(separatedBy: "/")[4]
|
||||
}
|
||||
|
||||
private static func extractThumbnailURL(from content: JSON) -> URL? {
|
||||
content.dictionaryValue["thumbnail"]?.url! ?? content.dictionaryValue["thumbnailUrl"]!.url!
|
||||
}
|
||||
|
||||
private static func buildThumbnailURL(from content: JSON, quality: Thumbnail.Quality) -> URL? {
|
||||
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)
|
||||
)!
|
||||
}
|
||||
|
||||
private static func extractDescription(from content: JSON) -> String? {
|
||||
guard var description = content.dictionaryValue["description"]?.string else {
|
||||
return nil
|
||||
}
|
||||
|
||||
description = description.replacingOccurrences(
|
||||
of: "<br/>|<br />|<br>",
|
||||
with: "\n",
|
||||
options: .regularExpression,
|
||||
range: nil
|
||||
)
|
||||
|
||||
description = description.replacingOccurrences(
|
||||
of: "<[^>]+>",
|
||||
with: "",
|
||||
options: .regularExpression,
|
||||
range: nil
|
||||
)
|
||||
|
||||
return description
|
||||
}
|
||||
|
||||
private static func extractVideos(from content: JSON) -> [Video] {
|
||||
content.arrayValue.compactMap(extractVideo(from:))
|
||||
}
|
||||
|
||||
private static func extractStreams(from content: JSON) -> [Stream] {
|
||||
var streams = [Stream]()
|
||||
|
||||
if let hlsURL = content.dictionaryValue["hls"]?.url {
|
||||
streams.append(Stream(hlsURL: hlsURL))
|
||||
}
|
||||
|
||||
guard let audioStream = PipedAPI.compatibleAudioStreams(from: content).first else {
|
||||
return streams
|
||||
}
|
||||
|
||||
let videoStreams = PipedAPI.compatibleVideoStream(from: content)
|
||||
|
||||
videoStreams.forEach { videoStream in
|
||||
let audioAsset = AVURLAsset(url: audioStream.dictionaryValue["url"]!.url!)
|
||||
let videoAsset = AVURLAsset(url: videoStream.dictionaryValue["url"]!.url!)
|
||||
|
||||
let videoOnly = videoStream.dictionaryValue["videoOnly"]?.boolValue ?? true
|
||||
let resolution = Stream.Resolution.from(resolution: videoStream.dictionaryValue["quality"]!.stringValue)
|
||||
|
||||
if videoOnly {
|
||||
streams.append(
|
||||
Stream(audioAsset: audioAsset, videoAsset: videoAsset, resolution: resolution, kind: .adaptive)
|
||||
)
|
||||
} else {
|
||||
streams.append(
|
||||
SingleAssetStream(avAsset: videoAsset, resolution: resolution, kind: .stream)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return streams
|
||||
}
|
||||
|
||||
private static func extractRelated(from content: JSON) -> [Video] {
|
||||
content
|
||||
.dictionaryValue["relatedStreams"]?
|
||||
.arrayValue
|
||||
.compactMap(extractVideo(from:)) ?? []
|
||||
}
|
||||
|
||||
private static func compatibleAudioStreams(from content: JSON) -> [JSON] {
|
||||
content
|
||||
.dictionaryValue["audioStreams"]?
|
||||
.arrayValue
|
||||
.filter { $0.dictionaryValue["format"]?.stringValue == "M4A" }
|
||||
.sorted {
|
||||
$0.dictionaryValue["bitrate"]?.intValue ?? 0 > $1.dictionaryValue["bitrate"]?.intValue ?? 0
|
||||
} ?? []
|
||||
}
|
||||
|
||||
private static func compatibleVideoStream(from content: JSON) -> [JSON] {
|
||||
content
|
||||
.dictionaryValue["videoStreams"]?
|
||||
.arrayValue
|
||||
.filter { $0.dictionaryValue["format"] == "MPEG_4" } ?? []
|
||||
}
|
||||
}
|
||||
86
Model/Applications/VideosAPI.swift
Normal file
@@ -0,0 +1,86 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Siesta
|
||||
|
||||
protocol VideosAPI {
|
||||
var account: Account! { get }
|
||||
var signedIn: Bool { get }
|
||||
|
||||
func channel(_ id: String) -> Resource
|
||||
func channelVideos(_ id: String) -> Resource
|
||||
func trending(country: Country, category: TrendingCategory?) -> Resource
|
||||
func search(_ query: SearchQuery) -> Resource
|
||||
func searchSuggestions(query: String) -> Resource
|
||||
|
||||
func video(_ id: Video.ID) -> Resource
|
||||
|
||||
var subscriptions: Resource? { get }
|
||||
var feed: Resource? { get }
|
||||
var home: Resource? { get }
|
||||
var popular: Resource? { get }
|
||||
var playlists: Resource? { get }
|
||||
|
||||
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void)
|
||||
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void)
|
||||
|
||||
func playlist(_ id: String) -> Resource?
|
||||
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource?
|
||||
func playlistVideos(_ id: String) -> Resource?
|
||||
|
||||
func channelPlaylist(_ id: String) -> Resource?
|
||||
|
||||
func loadDetails(_ item: PlayerQueueItem, completionHandler: @escaping (PlayerQueueItem) -> Void)
|
||||
func shareURL(_ item: ContentItem, frontendHost: String?, time: CMTime?) -> URL?
|
||||
}
|
||||
|
||||
extension VideosAPI {
|
||||
func loadDetails(_ item: PlayerQueueItem, completionHandler: @escaping (PlayerQueueItem) -> Void = { _ in }) {
|
||||
guard (item.video?.streams ?? []).isEmpty else {
|
||||
completionHandler(item)
|
||||
return
|
||||
}
|
||||
|
||||
video(item.videoID).load().onSuccess { response in
|
||||
guard let video: Video = response.typedContent() else {
|
||||
return
|
||||
}
|
||||
|
||||
var newItem = item
|
||||
newItem.video = video
|
||||
|
||||
completionHandler(newItem)
|
||||
}
|
||||
}
|
||||
|
||||
func shareURL(_ item: ContentItem, frontendHost: String? = nil, time: CMTime? = nil) -> URL? {
|
||||
guard let frontendHost = frontendHost ?? account.instance.frontendHost else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var urlComponents = account.instance.urlComponents
|
||||
urlComponents.host = frontendHost
|
||||
|
||||
var queryItems = [URLQueryItem]()
|
||||
|
||||
switch item.contentType {
|
||||
case .video:
|
||||
urlComponents.path = "/watch"
|
||||
queryItems.append(.init(name: "v", value: item.video.videoID))
|
||||
case .channel:
|
||||
urlComponents.path = "/channel/\(item.channel.id)"
|
||||
case .playlist:
|
||||
urlComponents.path = "/playlist"
|
||||
queryItems.append(.init(name: "list", value: item.playlist.id))
|
||||
}
|
||||
|
||||
if !time.isNil, time!.seconds.isFinite {
|
||||
queryItems.append(.init(name: "t", value: "\(Int(time!.seconds))s"))
|
||||
}
|
||||
|
||||
if !queryItems.isEmpty {
|
||||
urlComponents.queryItems = queryItems
|
||||
}
|
||||
|
||||
return urlComponents.url
|
||||
}
|
||||
}
|
||||
41
Model/Applications/VideosApp.swift
Normal file
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
enum VideosApp: String, CaseIterable {
|
||||
case invidious, piped
|
||||
|
||||
var name: String {
|
||||
rawValue.capitalized
|
||||
}
|
||||
|
||||
var supportsAccounts: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
var accountsUsePassword: Bool {
|
||||
self == .piped
|
||||
}
|
||||
|
||||
var supportsPopular: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var supportsSearchFilters: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var supportsSubscriptions: Bool {
|
||||
supportsAccounts
|
||||
}
|
||||
|
||||
var supportsTrendingCategories: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var supportsUserPlaylists: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var hasFrontendURL: Bool {
|
||||
self == .piped
|
||||
}
|
||||
}
|
||||
42
Model/Channel.swift
Normal file
@@ -0,0 +1,42 @@
|
||||
import AVFoundation
|
||||
import Defaults
|
||||
import Foundation
|
||||
import SwiftyJSON
|
||||
|
||||
struct Channel: Identifiable, Hashable {
|
||||
var id: String
|
||||
var name: String
|
||||
var thumbnailURL: URL?
|
||||
var videos = [Video]()
|
||||
|
||||
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 subscriptionsString: String? {
|
||||
if subscriptionsCount != nil, subscriptionsCount! > 0 {
|
||||
return subscriptionsCount!.formattedAsAbbreviation()
|
||||
}
|
||||
|
||||
return subscriptionsText
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
10
Model/ChannelPlaylist.swift
Normal file
@@ -0,0 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
struct ChannelPlaylist: Identifiable {
|
||||
var id: String = UUID().uuidString
|
||||
var title: String
|
||||
var thumbnailURL: URL?
|
||||
var channel: Channel?
|
||||
var videos = [Video]()
|
||||
var videosCount: Int?
|
||||
}
|
||||
40
Model/ContentItem.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
struct ContentItem: Identifiable {
|
||||
enum ContentType: String {
|
||||
case video, playlist, channel
|
||||
|
||||
private var sortOrder: Int {
|
||||
switch self {
|
||||
case .channel:
|
||||
return 1
|
||||
case .playlist:
|
||||
return 2
|
||||
default:
|
||||
return 3
|
||||
}
|
||||
}
|
||||
|
||||
static func < (lhs: ContentType, rhs: ContentType) -> Bool {
|
||||
lhs.sortOrder < rhs.sortOrder
|
||||
}
|
||||
}
|
||||
|
||||
var video: Video!
|
||||
var playlist: ChannelPlaylist!
|
||||
var channel: Channel!
|
||||
|
||||
var id: String = UUID().uuidString
|
||||
|
||||
static func array(of videos: [Video]) -> [ContentItem] {
|
||||
videos.map { ContentItem(video: $0) }
|
||||
}
|
||||
|
||||
static func < (lhs: ContentItem, rhs: ContentItem) -> Bool {
|
||||
lhs.contentType < rhs.contentType
|
||||
}
|
||||
|
||||
var contentType: ContentType {
|
||||
video.isNil ? (channel.isNil ? .playlist : .channel) : .video
|
||||
}
|
||||
}
|
||||
279
Model/Country.swift
Normal file
@@ -0,0 +1,279 @@
|
||||
// swiftlint:disable switch_case_on_newline
|
||||
import Defaults
|
||||
|
||||
enum Country: String, CaseIterable, Identifiable, Hashable, Defaults.Serializable {
|
||||
var id: String {
|
||||
rawValue
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
|
||||
case dz = "DZ"
|
||||
case ar = "AR"
|
||||
case au = "AU"
|
||||
case at = "AT"
|
||||
case az = "AZ"
|
||||
case bh = "BH"
|
||||
case bd = "BD"
|
||||
case by = "BY"
|
||||
case be = "BE"
|
||||
case bo = "BO"
|
||||
case ba = "BA"
|
||||
case br = "BR"
|
||||
case bg = "BG"
|
||||
case ca = "CA"
|
||||
case cl = "CL"
|
||||
case co = "CO"
|
||||
case cr = "CR"
|
||||
case hr = "HR"
|
||||
case cy = "CY"
|
||||
case cz = "CZ"
|
||||
case dk = "DK"
|
||||
case `do` = "DO"
|
||||
case ec = "EC"
|
||||
case eg = "EG"
|
||||
case sv = "SV"
|
||||
case ee = "EE"
|
||||
case fi = "FI"
|
||||
case fr = "FR"
|
||||
case ge = "GE"
|
||||
case de = "DE"
|
||||
case gh = "GH"
|
||||
case gr = "GR"
|
||||
case gt = "GT"
|
||||
case hn = "HN"
|
||||
case hk = "HK"
|
||||
case hu = "HU"
|
||||
case `is` = "IS"
|
||||
case `in` = "IN"
|
||||
case id = "ID"
|
||||
case iq = "IQ"
|
||||
case ie = "IE"
|
||||
case il = "IL"
|
||||
case it = "IT"
|
||||
case jm = "JM"
|
||||
case jp = "JP"
|
||||
case jo = "JO"
|
||||
case kz = "KZ"
|
||||
case ke = "KE"
|
||||
case kr = "KR"
|
||||
case kw = "KW"
|
||||
case lv = "LV"
|
||||
case lb = "LB"
|
||||
case ly = "LY"
|
||||
case li = "LI"
|
||||
case lt = "LT"
|
||||
case lu = "LU"
|
||||
case mk = "MK"
|
||||
case my = "MY"
|
||||
case mt = "MT"
|
||||
case mx = "MX"
|
||||
case me = "ME"
|
||||
case ma = "MA"
|
||||
case np = "NP"
|
||||
case nl = "NL"
|
||||
case nz = "NZ"
|
||||
case ni = "NI"
|
||||
case ng = "NG"
|
||||
case no = "NO"
|
||||
case om = "OM"
|
||||
case pk = "PK"
|
||||
case pa = "PA"
|
||||
case pg = "PG"
|
||||
case py = "PY"
|
||||
case pe = "PE"
|
||||
case ph = "PH"
|
||||
case pl = "PL"
|
||||
case pt = "PT"
|
||||
case pr = "PR"
|
||||
case qa = "QA"
|
||||
case ro = "RO"
|
||||
case ru = "RU"
|
||||
case sa = "SA"
|
||||
case sn = "SN"
|
||||
case rs = "RS"
|
||||
case sg = "SG"
|
||||
case sk = "SK"
|
||||
case si = "SI"
|
||||
case za = "ZA"
|
||||
case es = "ES"
|
||||
case lk = "LK"
|
||||
case se = "SE"
|
||||
case ch = "CH"
|
||||
case tw = "TW"
|
||||
case tz = "TZ"
|
||||
case th = "TH"
|
||||
case tn = "TN"
|
||||
case tr = "TR"
|
||||
case ug = "UG"
|
||||
case ua = "UA"
|
||||
case ae = "AE"
|
||||
case gb = "GB"
|
||||
case us = "US"
|
||||
case uy = "UY"
|
||||
case ve = "VE"
|
||||
case vn = "VN"
|
||||
case vi = "VI"
|
||||
case ye = "YE"
|
||||
case zw = "ZW"
|
||||
}
|
||||
|
||||
extension Country {
|
||||
var name: String {
|
||||
switch self {
|
||||
case .dz: return "Algeria"
|
||||
case .ar: return "Argentina"
|
||||
case .au: return "Australia"
|
||||
case .at: return "Austria"
|
||||
case .az: return "Azerbaijan"
|
||||
case .bh: return "Bahrain"
|
||||
case .bd: return "Bangladesh"
|
||||
case .by: return "Belarus"
|
||||
case .be: return "Belgium"
|
||||
case .bo: return "Bolivia (Plurinational State of)"
|
||||
case .ba: return "Bosnia and Herzegovina"
|
||||
case .br: return "Brazil"
|
||||
case .bg: return "Bulgaria"
|
||||
case .ca: return "Canada"
|
||||
case .cl: return "Chile"
|
||||
case .co: return "Colombia"
|
||||
case .cr: return "Costa Rica"
|
||||
case .hr: return "Croatia"
|
||||
case .cy: return "Cyprus"
|
||||
case .cz: return "Czechia"
|
||||
case .dk: return "Denmark"
|
||||
case .do: return "Dominican Republic"
|
||||
case .ec: return "Ecuador"
|
||||
case .eg: return "Egypt"
|
||||
case .sv: return "El Salvador"
|
||||
case .ee: return "Estonia"
|
||||
case .fi: return "Finland"
|
||||
case .fr: return "France"
|
||||
case .ge: return "Georgia"
|
||||
case .de: return "Germany"
|
||||
case .gh: return "Ghana"
|
||||
case .gr: return "Greece"
|
||||
case .gt: return "Guatemala"
|
||||
case .hn: return "Honduras"
|
||||
case .hk: return "Hong Kong"
|
||||
case .hu: return "Hungary"
|
||||
case .is: return "Iceland"
|
||||
case .in: return "India"
|
||||
case .id: return "Indonesia"
|
||||
case .iq: return "Iraq"
|
||||
case .ie: return "Ireland"
|
||||
case .il: return "Israel"
|
||||
case .it: return "Italy"
|
||||
case .jm: return "Jamaica"
|
||||
case .jp: return "Japan"
|
||||
case .jo: return "Jordan"
|
||||
case .kz: return "Kazakhstan"
|
||||
case .ke: return "Kenya"
|
||||
case .kr: return "Korea (Republic of)"
|
||||
case .kw: return "Kuwait"
|
||||
case .lv: return "Latvia"
|
||||
case .lb: return "Lebanon"
|
||||
case .ly: return "Libya"
|
||||
case .li: return "Liechtenstein"
|
||||
case .lt: return "Lithuania"
|
||||
case .lu: return "Luxembourg"
|
||||
case .mk: return "Macedonia (the former Yugoslav Republic of)"
|
||||
case .my: return "Malaysia"
|
||||
case .mt: return "Malta"
|
||||
case .mx: return "Mexico"
|
||||
case .me: return "Montenegro"
|
||||
case .ma: return "Morocco"
|
||||
case .np: return "Nepal"
|
||||
case .nl: return "Netherlands"
|
||||
case .nz: return "New Zealand"
|
||||
case .ni: return "Nicaragua"
|
||||
case .ng: return "Nigeria"
|
||||
case .no: return "Norway"
|
||||
case .om: return "Oman"
|
||||
case .pk: return "Pakistan"
|
||||
case .pa: return "Panama"
|
||||
case .pg: return "Papua New Guinea"
|
||||
case .py: return "Paraguay"
|
||||
case .pe: return "Peru"
|
||||
case .ph: return "Philippines"
|
||||
case .pl: return "Poland"
|
||||
case .pt: return "Portugal"
|
||||
case .pr: return "Puerto Rico"
|
||||
case .qa: return "Qatar"
|
||||
case .ro: return "Romania"
|
||||
case .ru: return "Russian Federation"
|
||||
case .sa: return "Saudi Arabia"
|
||||
case .sn: return "Senegal"
|
||||
case .rs: return "Serbia"
|
||||
case .sg: return "Singapore"
|
||||
case .sk: return "Slovakia"
|
||||
case .si: return "Slovenia"
|
||||
case .za: return "South Africa"
|
||||
case .es: return "Spain"
|
||||
case .lk: return "Sri Lanka"
|
||||
case .se: return "Sweden"
|
||||
case .ch: return "Switzerland"
|
||||
case .tw: return "Taiwan, Province of China[a]"
|
||||
case .tz: return "Tanzania, United Republic of"
|
||||
case .th: return "Thailand"
|
||||
case .tn: return "Tunisia"
|
||||
case .tr: return "Turkey"
|
||||
case .ug: return "Uganda"
|
||||
case .ua: return "Ukraine"
|
||||
case .ae: return "United Arab Emirates"
|
||||
case .gb: return "United Kingdom of Great Britain and Northern Ireland"
|
||||
case .us: return "United States of America"
|
||||
case .uy: return "Uruguay"
|
||||
case .ve: return "Venezuela (Bolivarian Republic of)"
|
||||
case .vn: return "Viet Nam"
|
||||
case .vi: return "Virgin Islands (U.S.)"
|
||||
case .ye: return "Yemen"
|
||||
case .zw: return "Zimbabwe"
|
||||
}
|
||||
}
|
||||
|
||||
var flag: String {
|
||||
let unicodeScalars = rawValue
|
||||
.unicodeScalars
|
||||
.map { $0.value + 0x1F1E6 - 65 }
|
||||
.compactMap(UnicodeScalar.init)
|
||||
var result = ""
|
||||
result.unicodeScalars.append(contentsOf: unicodeScalars)
|
||||
return result
|
||||
}
|
||||
|
||||
static func search(_ query: String) -> [Country] {
|
||||
if let country = searchByCode(query) {
|
||||
return [country]
|
||||
}
|
||||
|
||||
let countries = filteredCountries { stringFolding($0) == stringFolding(query) }
|
||||
|
||||
return countries.isEmpty ? searchByPartialName(query) : countries
|
||||
}
|
||||
|
||||
static func searchByCode(_ code: String) -> Country? {
|
||||
Country(rawValue: code.uppercased())
|
||||
}
|
||||
|
||||
static func searchByPartialName(_ name: String) -> [Country] {
|
||||
guard !name.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
return filteredCountries { stringFolding($0).contains(stringFolding(name)) }
|
||||
}
|
||||
|
||||
private static func stringFolding(_ string: String) -> String {
|
||||
string.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||||
}
|
||||
|
||||
private static func filteredCountries(_ predicate: (String) -> Bool) -> [Country] {
|
||||
Country.allCases
|
||||
.map { $0.name }
|
||||
.filter(predicate)
|
||||
.compactMap { string in Country.allCases.first { $0.name == string } }
|
||||
}
|
||||
}
|
||||
53
Model/FavoriteItem.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
struct FavoriteItem: Codable, Equatable, Identifiable, Defaults.Serializable {
|
||||
enum Section: Codable, Equatable, Defaults.Serializable {
|
||||
case subscriptions
|
||||
case popular
|
||||
case trending(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 .subscriptions:
|
||||
return "Subscriptions"
|
||||
case .popular:
|
||||
return "Popular"
|
||||
case let .trending(country, category):
|
||||
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):
|
||||
return name
|
||||
case let .channelPlaylist(_, name):
|
||||
return name
|
||||
case let .searchQuery(text, date, duration, order):
|
||||
var label = "Search: \"\(text)\""
|
||||
if !date.isEmpty, let date = SearchQuery.Date(rawValue: date), date != .any {
|
||||
label += " from \(date == .today ? date.name : " this \(date.name)")"
|
||||
}
|
||||
if !order.isEmpty, let order = SearchQuery.SortOrder(rawValue: order), order != .relevance {
|
||||
label += " by \(order.name)"
|
||||
}
|
||||
if !duration.isEmpty {
|
||||
label += " (\(duration))"
|
||||
}
|
||||
|
||||
return label
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func == (lhs: FavoriteItem, rhs: FavoriteItem) -> Bool {
|
||||
lhs.section == rhs.section
|
||||
}
|
||||
|
||||
var id = UUID().uuidString
|
||||
var section: Section
|
||||
}
|
||||
77
Model/FavoritesModel.swift
Normal file
@@ -0,0 +1,77 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
struct FavoritesModel {
|
||||
static let shared = FavoritesModel()
|
||||
|
||||
@Default(.favorites) var all
|
||||
|
||||
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 } }
|
||||
}
|
||||
}
|
||||
18
Model/MenuModel.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
final class MenuModel: ObservableObject {
|
||||
@Published var accounts: AccountsModel? { didSet { registerChildModel(accounts) } }
|
||||
@Published var navigation: NavigationModel? { didSet { registerChildModel(navigation) } }
|
||||
@Published var player: PlayerModel? { didSet { registerChildModel(player) } }
|
||||
|
||||
private var cancellables = [AnyCancellable]()
|
||||
|
||||
func registerChildModel<T: ObservableObject>(_ model: T?) {
|
||||
guard !model.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
cancellables.append(model!.objectWillChange.sink { [weak self] _ in self?.objectWillChange.send() })
|
||||
}
|
||||
}
|
||||
76
Model/NavigationModel.swift
Normal file
@@ -0,0 +1,76 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
final class NavigationModel: ObservableObject {
|
||||
enum TabSelection: Hashable {
|
||||
case favorites
|
||||
case subscriptions
|
||||
case popular
|
||||
case trending
|
||||
case playlists
|
||||
case channel(String)
|
||||
case playlist(String)
|
||||
case recentlyOpened(String)
|
||||
case nowPlaying
|
||||
case search
|
||||
|
||||
var playlistID: Playlist.ID? {
|
||||
if case let .playlist(id) = self {
|
||||
return id
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@Published var tabSelection: TabSelection! = .favorites
|
||||
|
||||
@Published var presentingAddToPlaylist = false
|
||||
@Published var videoToAddToPlaylist: Video!
|
||||
|
||||
@Published var presentingPlaylistForm = false
|
||||
@Published var editedPlaylist: Playlist!
|
||||
|
||||
@Published var presentingUnsubscribeAlert = false
|
||||
@Published var channelToUnsubscribe: Channel!
|
||||
|
||||
@Published var presentingChannel = false
|
||||
@Published var presentingPlaylist = false
|
||||
@Published var sidebarSectionChanged = false
|
||||
|
||||
@Published var presentingSettings = false
|
||||
@Published var presentingWelcomeScreen = false
|
||||
|
||||
var tabSelectionBinding: Binding<TabSelection> {
|
||||
Binding<TabSelection>(
|
||||
get: {
|
||||
self.tabSelection ?? .favorites
|
||||
},
|
||||
set: { newValue in
|
||||
self.tabSelection = newValue
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func presentAddToPlaylist(_ video: Video) {
|
||||
videoToAddToPlaylist = video
|
||||
presentingAddToPlaylist = true
|
||||
}
|
||||
|
||||
func presentEditPlaylistForm(_ playlist: Playlist?) {
|
||||
editedPlaylist = playlist
|
||||
presentingPlaylistForm = editedPlaylist != nil
|
||||
}
|
||||
|
||||
func presentNewPlaylistForm() {
|
||||
editedPlaylist = nil
|
||||
presentingPlaylistForm = true
|
||||
}
|
||||
|
||||
func presentUnsubscribeAlert(_ channel: Channel?) {
|
||||
channelToUnsubscribe = channel
|
||||
presentingUnsubscribeAlert = channelToUnsubscribe != nil
|
||||
}
|
||||
}
|
||||
|
||||
typealias TabSelection = NavigationModel.TabSelection
|
||||
520
Model/Player/PlayerModel.swift
Normal file
@@ -0,0 +1,520 @@
|
||||
import AVKit
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Logging
|
||||
import MediaPlayer
|
||||
#if !os(macOS)
|
||||
import UIKit
|
||||
#endif
|
||||
import Siesta
|
||||
import SwiftUI
|
||||
import SwiftyJSON
|
||||
|
||||
final class PlayerModel: ObservableObject {
|
||||
static let availableRates: [Float] = [0.5, 0.67, 0.8, 1, 1.25, 1.5, 2]
|
||||
let logger = Logger(label: "stream.yattee.app")
|
||||
|
||||
private(set) var player = AVPlayer()
|
||||
private(set) var playerView = Player()
|
||||
var controller: PlayerViewController? { didSet { playerView.controller = controller } }
|
||||
#if os(tvOS)
|
||||
var avPlayerViewController: AVPlayerViewController?
|
||||
#endif
|
||||
|
||||
@Published var presentingPlayer = false { didSet { pauseOnPlayerDismiss() } }
|
||||
|
||||
@Published var stream: Stream?
|
||||
@Published var currentRate: Float = 1.0 { didSet { player.rate = currentRate } }
|
||||
|
||||
@Published var availableStreams = [Stream]() { didSet { rebuildTVMenu() } }
|
||||
@Published var streamSelection: Stream? { didSet { rebuildTVMenu() } }
|
||||
|
||||
@Published var queue = [PlayerQueueItem]() { didSet { Defaults[.queue] = queue } }
|
||||
@Published var currentItem: PlayerQueueItem! { didSet { Defaults[.lastPlayed] = currentItem } }
|
||||
@Published var history = [PlayerQueueItem]() { didSet { Defaults[.history] = history } }
|
||||
|
||||
@Published var savedTime: CMTime?
|
||||
|
||||
@Published var playerNavigationLinkActive = false
|
||||
|
||||
@Published var sponsorBlock = SponsorBlockAPI()
|
||||
@Published var segmentRestorationTime: CMTime?
|
||||
@Published var lastSkipped: Segment? { didSet { rebuildTVMenu() } }
|
||||
@Published var restoredSegments = [Segment]()
|
||||
|
||||
var accounts: AccountsModel
|
||||
|
||||
var composition = AVMutableComposition()
|
||||
var loadedCompositionAssets = [AVMediaType]()
|
||||
|
||||
private var currentArtwork: MPMediaItemArtwork?
|
||||
private var frequentTimeObserver: Any?
|
||||
private var infrequentTimeObserver: Any?
|
||||
private var playerTimeControlStatusObserver: Any?
|
||||
|
||||
private var statusObservation: NSKeyValueObservation?
|
||||
|
||||
private var timeObserverThrottle = Throttle(interval: 2)
|
||||
|
||||
var playingInPictureInPicture = false
|
||||
|
||||
@Published var presentingErrorDetails = false
|
||||
var playerError: Error? { didSet {
|
||||
#if !os(tvOS)
|
||||
if !playerError.isNil {
|
||||
presentingErrorDetails = true
|
||||
}
|
||||
#endif
|
||||
}}
|
||||
|
||||
init(accounts: AccountsModel? = nil, instances _: InstancesModel? = nil) {
|
||||
self.accounts = accounts ?? AccountsModel()
|
||||
|
||||
addItemDidPlayToEndTimeObserver()
|
||||
addFrequentTimeObserver()
|
||||
addInfrequentTimeObserver()
|
||||
addPlayerTimeControlStatusObserver()
|
||||
}
|
||||
|
||||
func presentPlayer() {
|
||||
presentingPlayer = true
|
||||
}
|
||||
|
||||
func togglePlayer() {
|
||||
presentingPlayer.toggle()
|
||||
}
|
||||
|
||||
var isPlaying: Bool {
|
||||
player.timeControlStatus == .playing
|
||||
}
|
||||
|
||||
var time: CMTime? {
|
||||
currentItem?.playbackTime
|
||||
}
|
||||
|
||||
var live: Bool {
|
||||
currentItem?.video?.live ?? false
|
||||
}
|
||||
|
||||
var playerItemDuration: CMTime? {
|
||||
player.currentItem?.asset.duration
|
||||
}
|
||||
|
||||
var videoDuration: TimeInterval? {
|
||||
currentItem?.duration ?? currentVideo?.length
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
isPlaying ? pause() : play()
|
||||
}
|
||||
|
||||
func play() {
|
||||
guard player.timeControlStatus != .playing else {
|
||||
return
|
||||
}
|
||||
|
||||
player.play()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
guard player.timeControlStatus != .paused else {
|
||||
return
|
||||
}
|
||||
|
||||
player.pause()
|
||||
}
|
||||
|
||||
func upgradeToStream(_ stream: Stream) {
|
||||
if !self.stream.isNil, self.stream != stream {
|
||||
playStream(stream, of: currentVideo!, preservingTime: true)
|
||||
}
|
||||
}
|
||||
|
||||
func playStream(
|
||||
_ stream: Stream,
|
||||
of video: Video,
|
||||
preservingTime: Bool = false
|
||||
) {
|
||||
playerError = nil
|
||||
resetSegments()
|
||||
sponsorBlock.loadSegments(videoID: video.videoID, categories: Defaults[.sponsorBlockCategories])
|
||||
|
||||
if let url = stream.singleAssetURL {
|
||||
logger.info("playing stream with one asset\(stream.kind == .hls ? " (HLS)" : ""): \(url)")
|
||||
|
||||
insertPlayerItem(stream, for: video, preservingTime: preservingTime)
|
||||
} else {
|
||||
logger.info("playing stream with many assets:")
|
||||
logger.info("composition audio asset: \(stream.audioAsset.url)")
|
||||
logger.info("composition video asset: \(stream.videoAsset.url)")
|
||||
|
||||
loadComposition(stream, of: video, preservingTime: preservingTime)
|
||||
}
|
||||
|
||||
updateCurrentArtwork()
|
||||
}
|
||||
|
||||
private func pauseOnPlayerDismiss() {
|
||||
if !playingInPictureInPicture, !presentingPlayer {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func insertPlayerItem(
|
||||
_ stream: Stream,
|
||||
for video: Video,
|
||||
preservingTime: Bool = false
|
||||
) {
|
||||
let playerItem = playerItem(stream)
|
||||
guard playerItem != nil else {
|
||||
return
|
||||
}
|
||||
|
||||
attachMetadata(to: playerItem!, video: video, for: stream)
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
self.stream = stream
|
||||
self.composition = AVMutableComposition()
|
||||
}
|
||||
|
||||
let startPlaying = {
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
#endif
|
||||
|
||||
if self.isAutoplaying(playerItem!) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
||||
self?.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let replaceItemAndSeek = {
|
||||
self.player.replaceCurrentItem(with: playerItem)
|
||||
self.seekToSavedTime { finished in
|
||||
guard finished else {
|
||||
return
|
||||
}
|
||||
self.savedTime = nil
|
||||
|
||||
startPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
if preservingTime {
|
||||
if savedTime.isNil {
|
||||
saveTime {
|
||||
replaceItemAndSeek()
|
||||
startPlaying()
|
||||
}
|
||||
} else {
|
||||
replaceItemAndSeek()
|
||||
startPlaying()
|
||||
}
|
||||
} else {
|
||||
player.replaceCurrentItem(with: playerItem)
|
||||
startPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadComposition(
|
||||
_ stream: Stream,
|
||||
of video: Video,
|
||||
preservingTime: Bool = false
|
||||
) {
|
||||
loadedCompositionAssets = []
|
||||
loadCompositionAsset(stream.audioAsset, stream: stream, type: .audio, of: video, preservingTime: preservingTime)
|
||||
loadCompositionAsset(stream.videoAsset, stream: stream, type: .video, of: video, preservingTime: preservingTime)
|
||||
}
|
||||
|
||||
func loadCompositionAsset(
|
||||
_ asset: AVURLAsset,
|
||||
stream: Stream,
|
||||
type: AVMediaType,
|
||||
of video: Video,
|
||||
preservingTime: Bool = false
|
||||
) {
|
||||
asset.loadValuesAsynchronously(forKeys: ["playable"]) { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
self.logger.info("loading \(type.rawValue) track")
|
||||
|
||||
let assetTracks = asset.tracks(withMediaType: type)
|
||||
|
||||
guard let compositionTrack = self.composition.addMutableTrack(
|
||||
withMediaType: type,
|
||||
preferredTrackID: kCMPersistentTrackID_Invalid
|
||||
) else {
|
||||
self.logger.critical("composition \(type.rawValue) addMutableTrack FAILED")
|
||||
return
|
||||
}
|
||||
|
||||
guard let assetTrack = assetTracks.first else {
|
||||
self.logger.critical("asset \(type.rawValue) track FAILED")
|
||||
return
|
||||
}
|
||||
|
||||
try! compositionTrack.insertTimeRange(
|
||||
CMTimeRange(start: .zero, duration: CMTime.secondsInDefaultTimescale(video.length)),
|
||||
of: assetTrack,
|
||||
at: .zero
|
||||
)
|
||||
|
||||
self.logger.critical("\(type.rawValue) LOADED")
|
||||
|
||||
guard self.streamSelection == stream else {
|
||||
self.logger.critical("IGNORING LOADED")
|
||||
return
|
||||
}
|
||||
|
||||
self.loadedCompositionAssets.append(type)
|
||||
|
||||
if self.loadedCompositionAssets.count == 2 {
|
||||
self.insertPlayerItem(stream, for: video, preservingTime: preservingTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func playerItem(_ stream: Stream) -> AVPlayerItem? {
|
||||
if let url = stream.singleAssetURL {
|
||||
return AVPlayerItem(asset: AVURLAsset(url: url))
|
||||
} else {
|
||||
return AVPlayerItem(asset: composition)
|
||||
}
|
||||
}
|
||||
|
||||
private func attachMetadata(to item: AVPlayerItem, video: Video, for _: Stream? = nil) {
|
||||
#if !os(macOS)
|
||||
var externalMetadata = [
|
||||
makeMetadataItem(.commonIdentifierTitle, value: video.title),
|
||||
makeMetadataItem(.quickTimeMetadataGenre, value: video.genre ?? ""),
|
||||
makeMetadataItem(.commonIdentifierDescription, value: video.description ?? "")
|
||||
]
|
||||
if let thumbnailData = try? Data(contentsOf: video.thumbnailURL(quality: .medium)!),
|
||||
let image = UIImage(data: thumbnailData),
|
||||
let pngData = image.pngData()
|
||||
{
|
||||
let artworkItem = makeMetadataItem(.commonIdentifierArtwork, value: pngData)
|
||||
externalMetadata.append(artworkItem)
|
||||
}
|
||||
|
||||
item.externalMetadata = externalMetadata
|
||||
#endif
|
||||
|
||||
item.preferredForwardBufferDuration = 5
|
||||
|
||||
statusObservation?.invalidate()
|
||||
statusObservation = item.observe(\.status, options: [.old, .new]) { [weak self] playerItem, _ in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
switch playerItem.status {
|
||||
case .readyToPlay:
|
||||
if self.isAutoplaying(playerItem) {
|
||||
self.play()
|
||||
}
|
||||
case .failed:
|
||||
self.playerError = item.error
|
||||
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
private func makeMetadataItem(_ identifier: AVMetadataIdentifier, value: Any) -> AVMetadataItem {
|
||||
let item = AVMutableMetadataItem()
|
||||
|
||||
item.identifier = identifier
|
||||
item.value = value as? NSCopying & NSObjectProtocol
|
||||
item.extendedLanguageTag = "und"
|
||||
|
||||
return item.copy() as! AVMetadataItem
|
||||
}
|
||||
#endif
|
||||
|
||||
private func addItemDidPlayToEndTimeObserver() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(itemDidPlayToEndTime),
|
||||
name: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
@objc func itemDidPlayToEndTime() {
|
||||
currentItem.playbackTime = playerItemDuration
|
||||
|
||||
if queue.isEmpty {
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(false)
|
||||
#endif
|
||||
addCurrentItemToHistory()
|
||||
resetQueue()
|
||||
#if os(tvOS)
|
||||
avPlayerViewController!.dismiss(animated: true) { [weak self] in
|
||||
self?.controller!.dismiss(animated: true)
|
||||
}
|
||||
#endif
|
||||
presentingPlayer = false
|
||||
} else {
|
||||
advanceToNextItem()
|
||||
}
|
||||
}
|
||||
|
||||
private func saveTime(completionHandler: @escaping () -> Void = {}) {
|
||||
let currentTime = player.currentTime()
|
||||
|
||||
guard currentTime.seconds > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.savedTime = currentTime
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
|
||||
private func seekToSavedTime(completionHandler: @escaping (Bool) -> Void = { _ in }) {
|
||||
guard let time = savedTime else {
|
||||
return
|
||||
}
|
||||
|
||||
player.seek(
|
||||
to: time,
|
||||
toleranceBefore: .secondsInDefaultTimescale(1),
|
||||
toleranceAfter: .zero,
|
||||
completionHandler: completionHandler
|
||||
)
|
||||
}
|
||||
|
||||
private func addFrequentTimeObserver() {
|
||||
let interval = CMTime.secondsInDefaultTimescale(0.5)
|
||||
|
||||
frequentTimeObserver = player.addPeriodicTimeObserver(
|
||||
forInterval: interval,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
guard !self.currentItem.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
self.updateNowPlayingInfo()
|
||||
#endif
|
||||
|
||||
self.handleSegments(at: self.player.currentTime())
|
||||
}
|
||||
}
|
||||
|
||||
private func addInfrequentTimeObserver() {
|
||||
let interval = CMTime.secondsInDefaultTimescale(5)
|
||||
|
||||
infrequentTimeObserver = player.addPeriodicTimeObserver(
|
||||
forInterval: interval,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
guard !self.currentItem.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
self.timeObserverThrottle.execute {
|
||||
self.updateCurrentItemIntervals()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addPlayerTimeControlStatusObserver() {
|
||||
playerTimeControlStatusObserver = player.observe(\.timeControlStatus) { [weak self] player, _ in
|
||||
guard let self = self,
|
||||
self.player == player
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
if player.timeControlStatus != .waitingToPlayAtSpecifiedRate {
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
|
||||
if player.timeControlStatus == .playing, player.rate != self.currentRate {
|
||||
player.rate = self.currentRate
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
if player.timeControlStatus == .playing {
|
||||
ScreenSaverManager.shared.disable(reason: "Yattee is playing video")
|
||||
} else {
|
||||
ScreenSaverManager.shared.enable()
|
||||
}
|
||||
#endif
|
||||
|
||||
self.timeObserverThrottle.execute {
|
||||
self.updateCurrentItemIntervals()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateCurrentItemIntervals() {
|
||||
currentItem?.playbackTime = player.currentTime()
|
||||
currentItem?.videoDuration = player.currentItem?.asset.duration.seconds
|
||||
}
|
||||
|
||||
fileprivate func updateNowPlayingInfo() {
|
||||
let duration: Int? = currentItem.video.live ? nil : Int(currentItem.videoDuration ?? 0)
|
||||
let nowPlayingInfo: [String: AnyObject] = [
|
||||
MPMediaItemPropertyTitle: currentItem.video.title as AnyObject,
|
||||
MPMediaItemPropertyArtist: currentItem.video.author as AnyObject,
|
||||
MPMediaItemPropertyArtwork: currentArtwork as AnyObject,
|
||||
MPMediaItemPropertyPlaybackDuration: duration as AnyObject,
|
||||
MPNowPlayingInfoPropertyIsLiveStream: currentItem.video.live as AnyObject,
|
||||
MPNowPlayingInfoPropertyElapsedPlaybackTime: player.currentTime().seconds as AnyObject,
|
||||
MPNowPlayingInfoPropertyPlaybackQueueCount: queue.count as AnyObject,
|
||||
MPMediaItemPropertyMediaType: MPMediaType.anyVideo.rawValue as AnyObject
|
||||
]
|
||||
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
||||
}
|
||||
|
||||
private func updateCurrentArtwork() {
|
||||
guard let thumbnailData = try? Data(contentsOf: currentItem.video.thumbnailURL(quality: .medium)!) else {
|
||||
return
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
let image = NSImage(data: thumbnailData)
|
||||
#else
|
||||
let image = UIImage(data: thumbnailData)
|
||||
#endif
|
||||
|
||||
if image.isNil {
|
||||
return
|
||||
}
|
||||
|
||||
currentArtwork = MPMediaItemArtwork(boundsSize: image!.size) { _ in image! }
|
||||
}
|
||||
|
||||
func rateLabel(_ rate: Float) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.minimumFractionDigits = 0
|
||||
formatter.maximumFractionDigits = 2
|
||||
|
||||
return "\(formatter.string(from: NSNumber(value: rate))!)×"
|
||||
}
|
||||
}
|
||||
238
Model/Player/PlayerQueue.swift
Normal file
@@ -0,0 +1,238 @@
|
||||
import AVFoundation
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Siesta
|
||||
|
||||
extension PlayerModel {
|
||||
var currentVideo: Video? {
|
||||
currentItem?.video
|
||||
}
|
||||
|
||||
func playAll(_ videos: [Video]) {
|
||||
let first = videos.first
|
||||
|
||||
videos.forEach { video in
|
||||
enqueueVideo(video) { _, item in
|
||||
if item.video == first {
|
||||
self.advanceToItem(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func playNext(_ video: Video) {
|
||||
enqueueVideo(video, prepending: true) { _, item in
|
||||
if self.currentItem.isNil {
|
||||
self.advanceToItem(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func playNow(_ video: Video, at time: TimeInterval? = nil) {
|
||||
addCurrentItemToHistory()
|
||||
|
||||
enqueueVideo(video, prepending: true) { _, item in
|
||||
self.advanceToItem(item, at: time)
|
||||
}
|
||||
}
|
||||
|
||||
func playItem(_ item: PlayerQueueItem, video: Video? = nil, at time: TimeInterval? = nil) {
|
||||
currentItem = item
|
||||
|
||||
if !time.isNil {
|
||||
currentItem.playbackTime = .secondsInDefaultTimescale(time!)
|
||||
} else if currentItem.playbackTime.isNil {
|
||||
currentItem.playbackTime = .zero
|
||||
}
|
||||
|
||||
if video != nil {
|
||||
currentItem.video = video!
|
||||
}
|
||||
|
||||
savedTime = currentItem.playbackTime
|
||||
|
||||
loadAvailableStreams(currentVideo!) { streams in
|
||||
guard let stream = self.preferredStream(streams) else {
|
||||
return
|
||||
}
|
||||
|
||||
self.streamSelection = stream
|
||||
self.playStream(
|
||||
stream,
|
||||
of: self.currentVideo!,
|
||||
preservingTime: !self.currentItem.playbackTime.isNil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func preferredStream(_ streams: [Stream]) -> Stream? {
|
||||
let quality = Defaults[.quality]
|
||||
var streams = streams
|
||||
|
||||
if let id = Defaults[.playerInstanceID] {
|
||||
streams = streams.filter { $0.instance.id == id }
|
||||
}
|
||||
|
||||
switch quality {
|
||||
case .best:
|
||||
return streams.first { $0.kind == .hls } ?? streams.first
|
||||
default:
|
||||
let sorted = streams.filter { $0.kind != .hls }.sorted { $0.resolution > $1.resolution }
|
||||
return sorted.first(where: { $0.resolution.height <= quality.value.height })
|
||||
}
|
||||
}
|
||||
|
||||
func advanceToNextItem() {
|
||||
addCurrentItemToHistory()
|
||||
|
||||
if let nextItem = queue.first {
|
||||
advanceToItem(nextItem)
|
||||
}
|
||||
}
|
||||
|
||||
func advanceToItem(_ newItem: PlayerQueueItem, at time: TimeInterval? = nil) {
|
||||
addCurrentItemToHistory()
|
||||
|
||||
remove(newItem)
|
||||
|
||||
accounts.api.loadDetails(newItem) { newItem in
|
||||
self.playItem(newItem, video: newItem.video, at: time)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult func remove(_ item: PlayerQueueItem) -> PlayerQueueItem? {
|
||||
if let index = queue.firstIndex(where: { $0.videoID == item.videoID }) {
|
||||
return queue.remove(at: index)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func resetQueue() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
self.currentItem = nil
|
||||
self.stream = nil
|
||||
self.removeQueueItems()
|
||||
}
|
||||
|
||||
player.replaceCurrentItem(with: nil)
|
||||
}
|
||||
|
||||
func isAutoplaying(_ item: AVPlayerItem) -> Bool {
|
||||
player.currentItem == item && presentingPlayer
|
||||
}
|
||||
|
||||
@discardableResult func enqueueVideo(
|
||||
_ video: Video,
|
||||
play: Bool = false,
|
||||
atTime: CMTime? = nil,
|
||||
prepending: Bool = false,
|
||||
videoDetailsLoadHandler: @escaping (Video, PlayerQueueItem) -> Void = { _, _ in }
|
||||
) -> PlayerQueueItem? {
|
||||
let item = PlayerQueueItem(video, playbackTime: atTime)
|
||||
|
||||
queue.insert(item, at: prepending ? 0 : queue.endIndex)
|
||||
|
||||
accounts.api.loadDetails(item) { newItem in
|
||||
videoDetailsLoadHandler(newItem.video, newItem)
|
||||
|
||||
if play {
|
||||
self.playItem(newItem, video: video)
|
||||
}
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
func addCurrentItemToHistory() {
|
||||
if let item = currentItem, Defaults[.saveHistory] {
|
||||
addItemToHistory(item)
|
||||
}
|
||||
}
|
||||
|
||||
func addItemToHistory(_ item: PlayerQueueItem) {
|
||||
if let index = history.firstIndex(where: { $0.video?.videoID == item.video?.videoID }) {
|
||||
history.remove(at: index)
|
||||
}
|
||||
|
||||
history.insert(currentItem, at: 0)
|
||||
}
|
||||
|
||||
func playHistory(_ item: PlayerQueueItem) {
|
||||
var time = item.playbackTime
|
||||
|
||||
if item.shouldRestartPlaying {
|
||||
time = .zero
|
||||
}
|
||||
|
||||
let newItem = enqueueVideo(item.video, atTime: time, prepending: true)
|
||||
|
||||
advanceToItem(newItem!)
|
||||
|
||||
if let historyItemIndex = history.firstIndex(of: item) {
|
||||
history.remove(at: historyItemIndex)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult func removeHistory(_ item: PlayerQueueItem) -> PlayerQueueItem? {
|
||||
if let index = history.firstIndex(where: { $0 == item }) {
|
||||
return history.remove(at: index)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeQueueItems() {
|
||||
queue.removeAll()
|
||||
}
|
||||
|
||||
func removeHistoryItems() {
|
||||
history.removeAll()
|
||||
}
|
||||
|
||||
func loadHistoryDetails() {
|
||||
guard !accounts.current.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
queue = Defaults[.queue]
|
||||
queue.forEach { item in
|
||||
accounts.api.loadDetails(item) { newItem in
|
||||
if let index = self.queue.firstIndex(where: { $0.id == item.id }) {
|
||||
self.queue[index] = newItem
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var savedHistory = Defaults[.history]
|
||||
|
||||
if let lastPlayed = Defaults[.lastPlayed] {
|
||||
if let index = savedHistory.firstIndex(where: { $0.videoID == lastPlayed.videoID }) {
|
||||
var updatedLastPlayed = savedHistory[index]
|
||||
|
||||
updatedLastPlayed.playbackTime = lastPlayed.playbackTime
|
||||
updatedLastPlayed.videoDuration = lastPlayed.videoDuration
|
||||
|
||||
savedHistory.remove(at: index)
|
||||
savedHistory.insert(updatedLastPlayed, at: 0)
|
||||
} else {
|
||||
savedHistory.insert(lastPlayed, at: 0)
|
||||
}
|
||||
|
||||
Defaults[.lastPlayed] = nil
|
||||
}
|
||||
|
||||
history = savedHistory
|
||||
history.forEach { item in
|
||||
accounts.api.loadDetails(item) { newItem in
|
||||
if let index = self.history.firstIndex(where: { $0.id == item.id }) {
|
||||
self.history[index] = newItem
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
36
Model/Player/PlayerQueueItem.swift
Normal file
@@ -0,0 +1,36 @@
|
||||
import AVFoundation
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
struct PlayerQueueItem: Hashable, Identifiable, Defaults.Serializable {
|
||||
static let bridge = PlayerQueueItemBridge()
|
||||
|
||||
var id = UUID()
|
||||
var video: Video!
|
||||
var videoID: Video.ID
|
||||
var playbackTime: CMTime?
|
||||
var videoDuration: TimeInterval?
|
||||
|
||||
init(_ video: Video? = nil, videoID: Video.ID? = nil, playbackTime: CMTime? = nil, videoDuration: TimeInterval? = nil) {
|
||||
self.video = video
|
||||
self.videoID = videoID ?? video!.videoID
|
||||
self.playbackTime = playbackTime
|
||||
self.videoDuration = videoDuration
|
||||
}
|
||||
|
||||
var duration: TimeInterval {
|
||||
videoDuration ?? video.length
|
||||
}
|
||||
|
||||
var shouldRestartPlaying: Bool {
|
||||
guard let seconds = playbackTime?.seconds else {
|
||||
return false
|
||||
}
|
||||
|
||||
return duration - seconds <= 10
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
65
Model/Player/PlayerQueueItemBridge.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
import CoreMedia
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
struct PlayerQueueItemBridge: Defaults.Bridge {
|
||||
typealias Value = PlayerQueueItem
|
||||
typealias Serializable = [String: String]
|
||||
|
||||
func serialize(_ value: Value?) -> Serializable? {
|
||||
guard let value = value else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var playbackTime = ""
|
||||
if let time = value.playbackTime {
|
||||
if time.seconds.isFinite {
|
||||
playbackTime = String(time.seconds)
|
||||
}
|
||||
}
|
||||
|
||||
var videoDuration = ""
|
||||
if let duration = value.videoDuration {
|
||||
if duration.isFinite {
|
||||
videoDuration = String(duration)
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
"videoID": value.videoID,
|
||||
"playbackTime": playbackTime,
|
||||
"videoDuration": videoDuration
|
||||
]
|
||||
}
|
||||
|
||||
func deserialize(_ object: Serializable?) -> Value? {
|
||||
guard
|
||||
let object = object,
|
||||
let videoID = object["videoID"]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var playbackTime: CMTime?
|
||||
var videoDuration: TimeInterval?
|
||||
|
||||
if let time = object["playbackTime"],
|
||||
!time.isEmpty,
|
||||
let seconds = TimeInterval(time)
|
||||
{
|
||||
playbackTime = .secondsInDefaultTimescale(seconds)
|
||||
}
|
||||
|
||||
if let duration = object["videoDuration"],
|
||||
!duration.isEmpty
|
||||
{
|
||||
videoDuration = TimeInterval(duration)
|
||||
}
|
||||
|
||||
return PlayerQueueItem(
|
||||
videoID: videoID,
|
||||
playbackTime: playbackTime,
|
||||
videoDuration: videoDuration
|
||||
)
|
||||
}
|
||||
}
|
||||
79
Model/Player/PlayerSponsorBlock.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
import CoreMedia
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
extension PlayerModel {
|
||||
func handleSegments(at time: CMTime) {
|
||||
if let segment = lastSkipped {
|
||||
if time > .secondsInDefaultTimescale(segment.end + 10) {
|
||||
resetLastSegment()
|
||||
}
|
||||
}
|
||||
guard let firstSegment = sponsorBlock.segments.first(where: { $0.timeInSegment(time) }) else {
|
||||
return
|
||||
}
|
||||
|
||||
// find last segment in case they are 2 sec or less after each other
|
||||
// to avoid multiple skips in a row
|
||||
var nextSegments = [firstSegment]
|
||||
|
||||
while let segment = sponsorBlock.segments.first(where: {
|
||||
$0.timeInSegment(.secondsInDefaultTimescale(nextSegments.last!.end + 2))
|
||||
}) {
|
||||
nextSegments.append(segment)
|
||||
}
|
||||
|
||||
if let segmentToSkip = nextSegments.last(where: { $0.endTime <= playerItemDuration ?? .zero }),
|
||||
self.shouldSkip(segmentToSkip, at: time)
|
||||
{
|
||||
skip(segmentToSkip, at: time)
|
||||
}
|
||||
}
|
||||
|
||||
private func skip(_ segment: Segment, at time: CMTime) {
|
||||
guard segment.endTime.seconds <= playerItemDuration?.seconds ?? .infinity else {
|
||||
logger.error(
|
||||
"segment end time is: \(segment.end) when player item duration is: \(playerItemDuration?.seconds ?? .infinity)"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
player.seek(to: segment.endTime)
|
||||
lastSkipped = segment
|
||||
segmentRestorationTime = time
|
||||
logger.info("SponsorBlock skipping to: \(segment.end)")
|
||||
}
|
||||
|
||||
private func shouldSkip(_ segment: Segment, at time: CMTime) -> Bool {
|
||||
guard isPlaying,
|
||||
!restoredSegments.contains(segment),
|
||||
Defaults[.sponsorBlockCategories].contains(segment.category)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
return time.seconds - segment.start < 2 && segment.end - time.seconds > 2
|
||||
}
|
||||
|
||||
func restoreLastSkippedSegment() {
|
||||
guard let segment = lastSkipped,
|
||||
let time = segmentRestorationTime
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
restoredSegments.append(segment)
|
||||
player.seek(to: time)
|
||||
resetLastSegment()
|
||||
}
|
||||
|
||||
private func resetLastSegment() {
|
||||
lastSkipped = nil
|
||||
segmentRestorationTime = nil
|
||||
}
|
||||
|
||||
func resetSegments() {
|
||||
resetLastSegment()
|
||||
restoredSegments = []
|
||||
}
|
||||
}
|
||||
87
Model/Player/PlayerStreams.swift
Normal file
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
import Siesta
|
||||
import SwiftUI
|
||||
|
||||
extension PlayerModel {
|
||||
var isLoadingAvailableStreams: Bool {
|
||||
streamSelection.isNil || availableStreams.isEmpty
|
||||
}
|
||||
|
||||
var isLoadingStream: Bool {
|
||||
!stream.isNil && stream != streamSelection
|
||||
}
|
||||
|
||||
var availableStreamsSorted: [Stream] {
|
||||
availableStreams.sorted(by: streamsSorter)
|
||||
}
|
||||
|
||||
func loadAvailableStreams(
|
||||
_ video: Video,
|
||||
completionHandler: @escaping ([Stream]) -> Void = { _ in }
|
||||
) {
|
||||
availableStreams = []
|
||||
var instancesWithLoadedStreams = [Instance]()
|
||||
|
||||
InstancesModel.all.forEach { instance in
|
||||
fetchStreams(instance.anonymous.video(video.videoID), instance: instance, video: video) { _ in
|
||||
self.completeIfAllInstancesLoaded(
|
||||
instance: instance,
|
||||
streams: self.availableStreams,
|
||||
instancesWithLoadedStreams: &instancesWithLoadedStreams,
|
||||
completionHandler: completionHandler
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchStreams(
|
||||
_ resource: Resource,
|
||||
instance: Instance,
|
||||
video: Video,
|
||||
onCompletion: @escaping (ResponseInfo) -> Void = { _ in }
|
||||
) {
|
||||
resource
|
||||
.load()
|
||||
.onSuccess { response in
|
||||
if let video: Video = response.typedContent() {
|
||||
self.availableStreams += self.streamsWithInstance(instance: instance, streams: video.streams)
|
||||
}
|
||||
}
|
||||
.onCompletion(onCompletion)
|
||||
}
|
||||
|
||||
private func completeIfAllInstancesLoaded(
|
||||
instance: Instance,
|
||||
streams: [Stream],
|
||||
instancesWithLoadedStreams: inout [Instance],
|
||||
completionHandler: @escaping ([Stream]) -> Void
|
||||
) {
|
||||
instancesWithLoadedStreams.append(instance)
|
||||
rebuildTVMenu()
|
||||
|
||||
if InstancesModel.all.count == instancesWithLoadedStreams.count {
|
||||
completionHandler(streams.sorted { $0.kind < $1.kind })
|
||||
}
|
||||
}
|
||||
|
||||
func streamsWithInstance(instance: Instance, streams: [Stream]) -> [Stream] {
|
||||
streams.map { stream in
|
||||
stream.instance = instance
|
||||
|
||||
if instance.app == .invidious {
|
||||
stream.audioAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: stream.audioAsset)
|
||||
stream.videoAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: stream.videoAsset)
|
||||
}
|
||||
|
||||
return stream
|
||||
}
|
||||
}
|
||||
|
||||
func streamsSorter(_ lhs: Stream, _ rhs: Stream) -> Bool {
|
||||
if lhs.resolution.isNil || rhs.resolution.isNil {
|
||||
return lhs.kind < rhs.kind
|
||||
}
|
||||
|
||||
return lhs.kind == rhs.kind ? (lhs.resolution.height > rhs.resolution.height) : (lhs.kind < rhs.kind)
|
||||
}
|
||||
}
|
||||
76
Model/Player/PlayerTVMenu.swift
Normal file
@@ -0,0 +1,76 @@
|
||||
import Foundation
|
||||
#if !os(macOS)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
extension PlayerModel {
|
||||
#if os(tvOS)
|
||||
var streamsMenu: UIMenu {
|
||||
UIMenu(
|
||||
title: "Streams",
|
||||
image: UIImage(systemName: "antenna.radiowaves.left.and.right"),
|
||||
children: streamsMenuActions
|
||||
)
|
||||
}
|
||||
|
||||
var streamsMenuActions: [UIAction] {
|
||||
guard !availableStreams.isEmpty else {
|
||||
return [ // swiftlint:disable:this implicit_return
|
||||
UIAction(title: "Empty", attributes: .disabled) { _ in }
|
||||
]
|
||||
}
|
||||
|
||||
return availableStreamsSorted.map { stream in
|
||||
let state = stream == streamSelection ? UIAction.State.on : .off
|
||||
|
||||
return UIAction(title: stream.description, state: state) { _ in
|
||||
self.streamSelection = stream
|
||||
self.upgradeToStream(stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var restoreLastSkippedSegmentAction: UIAction? {
|
||||
guard let segment = lastSkipped else {
|
||||
return nil // swiftlint:disable:this implicit_return
|
||||
}
|
||||
|
||||
return UIAction(
|
||||
title: "Restore \(segment.title())",
|
||||
image: UIImage(systemName: "arrow.uturn.left.circle")
|
||||
) { _ in
|
||||
self.restoreLastSkippedSegment()
|
||||
}
|
||||
}
|
||||
|
||||
private var rateMenu: UIMenu {
|
||||
UIMenu(title: "Playback rate", image: UIImage(systemName: rateMenuSystemImage), children: rateMenuActions)
|
||||
}
|
||||
|
||||
private var rateMenuSystemImage: String {
|
||||
[0.0, 1.0].contains(currentRate) ? "speedometer" : (currentRate < 1.0 ? "tortoise.fill" : "hare.fill")
|
||||
}
|
||||
|
||||
private var rateMenuActions: [UIAction] {
|
||||
PlayerModel.availableRates.map { rate in
|
||||
let image = currentRate == Float(rate) ? UIImage(systemName: "checkmark") : nil
|
||||
|
||||
return UIAction(title: rateLabel(rate), image: image) { _ in
|
||||
DispatchQueue.main.async {
|
||||
self.currentRate = rate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
func rebuildTVMenu() {
|
||||
#if os(tvOS)
|
||||
avPlayerViewController?.transportBarCustomMenuItems = [
|
||||
restoreLastSkippedSegmentAction,
|
||||
rateMenu,
|
||||
streamsMenu
|
||||
].compactMap { $0 }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
34
Model/Player/ScreenSaverManager.swift
Normal file
@@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
import IOKit.pwr_mgt
|
||||
|
||||
struct ScreenSaverManager {
|
||||
static var shared = ScreenSaverManager()
|
||||
|
||||
var noSleepAssertion: IOPMAssertionID = 0
|
||||
var noSleepReturn: IOReturn?
|
||||
|
||||
var enabled: Bool {
|
||||
noSleepReturn == nil
|
||||
}
|
||||
|
||||
@discardableResult mutating func disable(reason: String = "Unknown reason") -> Bool {
|
||||
guard enabled else {
|
||||
return false
|
||||
}
|
||||
|
||||
noSleepReturn = IOPMAssertionCreateWithName(kIOPMAssertionTypeNoDisplaySleep as CFString,
|
||||
IOPMAssertionLevel(kIOPMAssertionLevelOn),
|
||||
reason as CFString,
|
||||
&noSleepAssertion)
|
||||
return noSleepReturn == kIOReturnSuccess
|
||||
}
|
||||
|
||||
@discardableResult mutating func enable() -> Bool {
|
||||
if noSleepReturn != nil {
|
||||
_ = IOPMAssertionRelease(noSleepAssertion) == kIOReturnSuccess
|
||||
noSleepReturn = nil
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
47
Model/Playlist.swift
Normal file
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
import SwiftyJSON
|
||||
|
||||
struct Playlist: Identifiable, Equatable, Hashable {
|
||||
enum Visibility: String, CaseIterable, Identifiable {
|
||||
case `public`, unlisted, `private`
|
||||
|
||||
var id: String {
|
||||
rawValue
|
||||
}
|
||||
|
||||
var name: String {
|
||||
rawValue.capitalized
|
||||
}
|
||||
}
|
||||
|
||||
let id: String
|
||||
var title: String
|
||||
var visibility: Visibility
|
||||
|
||||
var updated: TimeInterval
|
||||
|
||||
var videos = [Video]()
|
||||
|
||||
init(id: String, title: String, visibility: Visibility, updated: TimeInterval) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.visibility = visibility
|
||||
self.updated = updated
|
||||
}
|
||||
|
||||
init(_ json: JSON) {
|
||||
id = json["playlistId"].stringValue
|
||||
title = json["title"].stringValue
|
||||
visibility = json["isListed"].boolValue ? .public : .private
|
||||
updated = json["updated"].doubleValue
|
||||
videos = json["videos"].arrayValue.map { InvidiousAPI.extractVideo(from: $0) }
|
||||
}
|
||||
|
||||
static func == (lhs: Playlist, rhs: Playlist) -> Bool {
|
||||
lhs.id == rhs.id && lhs.updated == rhs.updated
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
77
Model/PlaylistsModel.swift
Normal file
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
import Siesta
|
||||
import SwiftUI
|
||||
|
||||
final class PlaylistsModel: ObservableObject {
|
||||
@Published var playlists = [Playlist]()
|
||||
|
||||
var accounts = AccountsModel()
|
||||
|
||||
init(_ playlists: [Playlist] = [Playlist]()) {
|
||||
self.playlists = playlists
|
||||
}
|
||||
|
||||
var all: [Playlist] {
|
||||
playlists.sorted { $0.title.lowercased() < $1.title.lowercased() }
|
||||
}
|
||||
|
||||
func find(id: Playlist.ID?) -> Playlist? {
|
||||
if id.isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return playlists.first { $0.id == id! }
|
||||
}
|
||||
|
||||
var isEmpty: Bool {
|
||||
playlists.isEmpty
|
||||
}
|
||||
|
||||
func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) {
|
||||
guard !resource.isNil else {
|
||||
playlists = []
|
||||
return
|
||||
}
|
||||
|
||||
let request = force ? resource?.load() : resource?.loadIfNeeded()
|
||||
|
||||
guard !request.isNil else {
|
||||
onSuccess()
|
||||
return
|
||||
}
|
||||
|
||||
request?
|
||||
.onSuccess { resource in
|
||||
if let playlists: [Playlist] = resource.typedContent() {
|
||||
self.playlists = playlists
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
.onFailure { _ in
|
||||
self.playlists = []
|
||||
}
|
||||
}
|
||||
|
||||
func addVideo(playlistID: Playlist.ID, videoID: Video.ID, onSuccess: @escaping () -> Void = {}) {
|
||||
let resource = accounts.api.playlistVideos(playlistID)
|
||||
let body = ["videoId": videoID]
|
||||
|
||||
resource?.request(.post, json: body).onSuccess { _ in
|
||||
self.load(force: true)
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
func removeVideo(videoIndexID: String, playlistID: Playlist.ID, onSuccess: @escaping () -> Void = {}) {
|
||||
let resource = accounts.api.playlistVideo(playlistID, videoIndexID)
|
||||
|
||||
resource?.request(.delete).onSuccess { _ in
|
||||
self.load(force: true)
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
private var resource: Resource? {
|
||||
accounts.api.playlists
|
||||
}
|
||||
}
|
||||
22
Model/PlaylistsProvider.swift
Normal file
@@ -0,0 +1,22 @@
|
||||
import Alamofire
|
||||
import Foundation
|
||||
import SwiftyJSON
|
||||
|
||||
final class PlaylistsProvider: DataProvider {
|
||||
@Published var playlists = [Playlist]()
|
||||
|
||||
let profile = Profile()
|
||||
|
||||
func load(successHandler: @escaping ([Playlist]) -> Void = { _ in }) {
|
||||
let headers = HTTPHeaders([HTTPHeader(name: "Cookie", value: "SID=\(profile.sid)")])
|
||||
DataProvider.request("auth/playlists", headers: headers).responseJSON { response in
|
||||
switch response.result {
|
||||
case let .success(value):
|
||||
self.playlists = JSON(value).arrayValue.map { Playlist($0) }
|
||||
successHandler(self.playlists)
|
||||
case let .failure(error):
|
||||
print(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
148
Model/RecentsModel.swift
Normal file
@@ -0,0 +1,148 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
final class RecentsModel: ObservableObject {
|
||||
@Default(.recentlyOpened) var items
|
||||
|
||||
func clear() {
|
||||
items = []
|
||||
}
|
||||
|
||||
func clearQueries() {
|
||||
items.removeAll { $0.type == .query }
|
||||
}
|
||||
|
||||
func add(_ item: RecentItem) {
|
||||
if let index = items.firstIndex(where: { $0.id == item.id }) {
|
||||
items.remove(at: index)
|
||||
}
|
||||
|
||||
items.append(item)
|
||||
}
|
||||
|
||||
func close(_ item: RecentItem) {
|
||||
if let index = items.firstIndex(where: { $0.id == item.id }) {
|
||||
items.remove(at: index)
|
||||
}
|
||||
}
|
||||
|
||||
func addQuery(_ query: String) {
|
||||
if !query.isEmpty {
|
||||
add(.init(from: query))
|
||||
}
|
||||
}
|
||||
|
||||
var presentedChannel: Channel? {
|
||||
if let recent = items.last(where: { $0.type == .channel }) {
|
||||
return recent.channel
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var presentedPlaylist: ChannelPlaylist? {
|
||||
if let recent = items.last(where: { $0.type == .playlist }) {
|
||||
return recent.playlist
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
struct RecentItem: Defaults.Serializable, Identifiable {
|
||||
static var bridge = RecentItemBridge()
|
||||
|
||||
enum ItemType: String {
|
||||
case channel, playlist, query
|
||||
}
|
||||
|
||||
var type: ItemType
|
||||
var id: String
|
||||
var title: String
|
||||
|
||||
var tag: String {
|
||||
"recent\(type.rawValue.capitalized)\(id)"
|
||||
}
|
||||
|
||||
var query: SearchQuery? {
|
||||
guard type == .query else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return SearchQuery(query: title)
|
||||
}
|
||||
|
||||
var channel: Channel? {
|
||||
guard type == .channel else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Channel(id: id, name: title)
|
||||
}
|
||||
|
||||
var playlist: ChannelPlaylist? {
|
||||
guard type == .playlist else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return ChannelPlaylist(id: id, title: title)
|
||||
}
|
||||
|
||||
init(type: ItemType, identifier: String, title: String) {
|
||||
self.type = type
|
||||
id = identifier
|
||||
self.title = title
|
||||
}
|
||||
|
||||
init(from channel: Channel) {
|
||||
type = .channel
|
||||
id = channel.id
|
||||
title = channel.name
|
||||
}
|
||||
|
||||
init(from query: String) {
|
||||
type = .query
|
||||
id = query
|
||||
title = query
|
||||
}
|
||||
|
||||
init(from playlist: ChannelPlaylist) {
|
||||
type = .playlist
|
||||
id = playlist.id
|
||||
title = playlist.title
|
||||
}
|
||||
}
|
||||
|
||||
struct RecentItemBridge: Defaults.Bridge {
|
||||
typealias Value = RecentItem
|
||||
typealias Serializable = [String: String]
|
||||
|
||||
func serialize(_ value: Value?) -> Serializable? {
|
||||
guard let value = value else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return [
|
||||
"type": value.type.rawValue,
|
||||
"identifier": value.id,
|
||||
"title": value.title
|
||||
]
|
||||
}
|
||||
|
||||
func deserialize(_ object: Serializable?) -> Value? {
|
||||
guard
|
||||
let object = object,
|
||||
let type = object["type"],
|
||||
let identifier = object["identifier"],
|
||||
let title = object["title"]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return RecentItem(
|
||||
type: .init(rawValue: type)!,
|
||||
identifier: identifier,
|
||||
title: title
|
||||
)
|
||||
}
|
||||
}
|
||||
108
Model/Search/SearchModel.swift
Normal file
@@ -0,0 +1,108 @@
|
||||
import Defaults
|
||||
import Siesta
|
||||
import SwiftUI
|
||||
|
||||
final class SearchModel: ObservableObject {
|
||||
@Published var store = Store<[ContentItem]>()
|
||||
|
||||
var accounts = AccountsModel()
|
||||
@Published var query = SearchQuery()
|
||||
@Published var queryText = ""
|
||||
@Published var querySuggestions = Store<[String]>()
|
||||
|
||||
@Published var fieldIsFocused = false
|
||||
|
||||
private var previousResource: Resource?
|
||||
private var resource: Resource!
|
||||
|
||||
var isLoading: Bool {
|
||||
resource?.isLoading ?? false
|
||||
}
|
||||
|
||||
func changeQuery(_ changeHandler: @escaping (SearchQuery) -> Void = { _ in }) {
|
||||
changeHandler(query)
|
||||
|
||||
let newResource = accounts.api.search(query)
|
||||
guard newResource != previousResource else {
|
||||
return
|
||||
}
|
||||
|
||||
previousResource?.removeObservers(ownedBy: store)
|
||||
previousResource = newResource
|
||||
|
||||
resource = newResource
|
||||
resource.addObserver(store)
|
||||
|
||||
if !query.isEmpty {
|
||||
loadResourceIfNeededAndReplaceStore()
|
||||
}
|
||||
}
|
||||
|
||||
func resetQuery(_ query: SearchQuery = SearchQuery()) {
|
||||
self.query = query
|
||||
|
||||
let newResource = accounts.api.search(query)
|
||||
guard newResource != previousResource else {
|
||||
return
|
||||
}
|
||||
|
||||
store.replace([])
|
||||
|
||||
previousResource?.removeObservers(ownedBy: store)
|
||||
previousResource = newResource
|
||||
|
||||
resource = newResource
|
||||
resource.addObserver(store)
|
||||
|
||||
if !query.isEmpty {
|
||||
loadResourceIfNeededAndReplaceStore()
|
||||
}
|
||||
}
|
||||
|
||||
func loadResourceIfNeededAndReplaceStore() {
|
||||
let currentResource = resource!
|
||||
|
||||
if let request = resource.loadIfNeeded() {
|
||||
request.onSuccess { response in
|
||||
if let results: [ContentItem] = response.typedContent() {
|
||||
self.replace(results, for: currentResource)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
replace(store.collection, for: currentResource)
|
||||
}
|
||||
}
|
||||
|
||||
func replace(_ videos: [ContentItem], for resource: Resource) {
|
||||
if self.resource == resource {
|
||||
store = Store<[ContentItem]>(videos)
|
||||
}
|
||||
}
|
||||
|
||||
private var suggestionsDebounceTimer: Timer?
|
||||
|
||||
func loadSuggestions(_ query: String) {
|
||||
guard !query.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
suggestionsDebounceTimer?.invalidate()
|
||||
|
||||
suggestionsDebounceTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { _ in
|
||||
let resource = self.accounts.api.searchSuggestions(query: query)
|
||||
|
||||
resource.addObserver(self.querySuggestions)
|
||||
resource.loadIfNeeded()
|
||||
|
||||
if let request = resource.loadIfNeeded() {
|
||||
request.onSuccess { response in
|
||||
if let suggestions: [String] = response.typedContent() {
|
||||
self.querySuggestions = Store<[String]>(suggestions)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.querySuggestions = Store<[String]>(self.querySuggestions.collection)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
77
Model/Search/SearchQuery.swift
Normal file
@@ -0,0 +1,77 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
final class SearchQuery: ObservableObject {
|
||||
enum Date: String, CaseIterable, Identifiable {
|
||||
case any, hour, today, week, month, year
|
||||
|
||||
var id: SearchQuery.Date.RawValue {
|
||||
rawValue
|
||||
}
|
||||
|
||||
var name: String {
|
||||
rawValue.capitalized
|
||||
}
|
||||
}
|
||||
|
||||
enum Duration: String, CaseIterable, Identifiable {
|
||||
case any, short, long
|
||||
|
||||
var id: SearchQuery.Duration.RawValue {
|
||||
rawValue
|
||||
}
|
||||
|
||||
var name: String {
|
||||
rawValue.capitalized
|
||||
}
|
||||
}
|
||||
|
||||
enum SortOrder: String, CaseIterable, Identifiable {
|
||||
case relevance, rating, uploadDate, viewCount
|
||||
|
||||
var id: SearchQuery.SortOrder.RawValue {
|
||||
rawValue
|
||||
}
|
||||
|
||||
var name: String {
|
||||
switch self {
|
||||
case .uploadDate:
|
||||
return "Date"
|
||||
case .viewCount:
|
||||
return "Views"
|
||||
default:
|
||||
return rawValue.capitalized
|
||||
}
|
||||
}
|
||||
|
||||
var parameter: String {
|
||||
switch self {
|
||||
case .uploadDate:
|
||||
return "upload_date"
|
||||
case .viewCount:
|
||||
return "view_count"
|
||||
default:
|
||||
return rawValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published var query: String
|
||||
@Published var sortBy: SearchQuery.SortOrder = .relevance
|
||||
@Published var date: SearchQuery.Date? = .month
|
||||
@Published var duration: SearchQuery.Duration?
|
||||
|
||||
@Published var page = 1
|
||||
|
||||
init(query: String = "", page: Int = 1, sortBy: SearchQuery.SortOrder = .relevance, date: SearchQuery.Date? = nil, duration: SearchQuery.Duration? = nil) {
|
||||
self.query = query
|
||||
self.page = page
|
||||
self.sortBy = sortBy
|
||||
self.date = date
|
||||
self.duration = duration
|
||||
}
|
||||
|
||||
var isEmpty: Bool {
|
||||
query.isEmpty
|
||||
}
|
||||
}
|
||||
46
Model/Segment.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
import SwiftyJSON
|
||||
|
||||
// swiftlint:disable:next final_class
|
||||
class Segment: ObservableObject, Hashable {
|
||||
let category: String
|
||||
let segment: [Double]
|
||||
let uuid: String
|
||||
let videoDuration: Int
|
||||
|
||||
var start: Double {
|
||||
segment.first!
|
||||
}
|
||||
|
||||
var end: Double {
|
||||
segment.last!
|
||||
}
|
||||
|
||||
var endTime: CMTime {
|
||||
CMTime(seconds: end, preferredTimescale: 1000)
|
||||
}
|
||||
|
||||
init(category: String, segment: [Double], uuid: String, videoDuration: Int) {
|
||||
self.category = category
|
||||
self.segment = segment
|
||||
self.uuid = uuid
|
||||
self.videoDuration = videoDuration
|
||||
}
|
||||
|
||||
func timeInSegment(_ time: CMTime) -> Bool {
|
||||
(start ... end).contains(time.seconds)
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(uuid)
|
||||
}
|
||||
|
||||
static func == (lhs: Segment, rhs: Segment) -> Bool {
|
||||
lhs.uuid == rhs.uuid
|
||||
}
|
||||
|
||||
func title() -> String {
|
||||
category
|
||||
}
|
||||
}
|
||||
12
Model/SingleAssetStream.swift
Normal file
@@ -0,0 +1,12 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
final class SingleAssetStream: Stream {
|
||||
var avAsset: AVURLAsset
|
||||
|
||||
init(avAsset: AVURLAsset, resolution: Resolution, kind: Kind, encoding: String = "") {
|
||||
self.avAsset = avAsset
|
||||
|
||||
super.init(audioAsset: avAsset, videoAsset: avAsset, resolution: resolution, kind: kind, encoding: encoding)
|
||||
}
|
||||
}
|
||||
73
Model/SponsorBlock/SponsorBlockAPI.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
import Alamofire
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Logging
|
||||
import SwiftyJSON
|
||||
|
||||
final class SponsorBlockAPI: ObservableObject {
|
||||
static let categories = ["sponsor", "selfpromo", "intro", "outro", "interaction", "music_offtopic"]
|
||||
|
||||
let logger = Logger(label: "net.yattee.app.sb")
|
||||
|
||||
@Published var videoID: String?
|
||||
@Published var segments = [Segment]()
|
||||
|
||||
static func categoryDescription(_ name: String) -> String? {
|
||||
guard SponsorBlockAPI.categories.contains(name) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "selfpromo":
|
||||
return "Self-promotion"
|
||||
case "music_offtopic":
|
||||
return "Offtopic in Music Videos"
|
||||
default:
|
||||
return name.capitalized
|
||||
}
|
||||
}
|
||||
|
||||
func loadSegments(videoID: String, categories: Set<String>) {
|
||||
guard !skipSegmentsURL.isNil, self.videoID != videoID else {
|
||||
return
|
||||
}
|
||||
|
||||
self.videoID = videoID
|
||||
|
||||
requestSegments(categories: categories)
|
||||
}
|
||||
|
||||
private func requestSegments(categories: Set<String>) {
|
||||
guard let url = skipSegmentsURL, !categories.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
AF.request(url, parameters: parameters(categories: categories)).responseJSON { response in
|
||||
switch response.result {
|
||||
case let .success(value):
|
||||
self.segments = JSON(value).arrayValue.map(SponsorBlockSegment.init).sorted { $0.end < $1.end }
|
||||
|
||||
self.logger.info("loaded \(self.segments.count) SponsorBlock segments")
|
||||
self.segments.forEach {
|
||||
self.logger.info("\($0.start) -> \($0.end)")
|
||||
}
|
||||
case let .failure(error):
|
||||
self.segments = []
|
||||
|
||||
self.logger.error("failed to load SponsorBlock segments: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var skipSegmentsURL: String? {
|
||||
let url = Defaults[.sponsorBlockInstance]
|
||||
return url.isEmpty ? nil : "\(url)/api/skipSegments"
|
||||
}
|
||||
|
||||
private func parameters(categories: Set<String>) -> [String: String] {
|
||||
[
|
||||
"videoID": videoID!,
|
||||
"categories": JSON(Array(categories)).rawString(String.Encoding.utf8)!
|
||||
]
|
||||
}
|
||||
}
|
||||
24
Model/SponsorBlock/SponsorBlockSegment.swift
Normal file
@@ -0,0 +1,24 @@
|
||||
import Foundation
|
||||
import SwiftyJSON
|
||||
|
||||
final class SponsorBlockSegment: Segment {
|
||||
init(_ json: JSON) {
|
||||
super.init(
|
||||
category: json["category"].string!,
|
||||
segment: json["segment"].array!.map { $0.double! },
|
||||
uuid: json["UUID"].string!,
|
||||
videoDuration: json["videoDuration"].int!
|
||||
)
|
||||
}
|
||||
|
||||
override func title() -> String {
|
||||
switch category {
|
||||
case "selfpromo":
|
||||
return "self-promotion"
|
||||
case "music_offtopic":
|
||||
return "offtopic"
|
||||
default:
|
||||
return category
|
||||
}
|
||||
}
|
||||
}
|
||||
25
Model/Store.swift
Normal file
@@ -0,0 +1,25 @@
|
||||
import Foundation
|
||||
import Siesta
|
||||
|
||||
final class Store<Data>: ResourceObserver, ObservableObject {
|
||||
@Published private var all: Data?
|
||||
|
||||
var collection: Data { all ?? ([] as! Data) }
|
||||
var item: Data? { all }
|
||||
|
||||
init(_ data: Data? = nil) {
|
||||
if data != nil {
|
||||
replace(data!)
|
||||
}
|
||||
}
|
||||
|
||||
func resourceChanged(_ resource: Resource, event _: ResourceEvent) {
|
||||
if let items: Data = resource.typedContent() {
|
||||
replace(items)
|
||||
}
|
||||
}
|
||||
|
||||
func replace(_ items: Data) {
|
||||
all = items
|
||||
}
|
||||
}
|
||||
125
Model/Stream.swift
Normal file
@@ -0,0 +1,125 @@
|
||||
import AVFoundation
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
// swiftlint:disable:next final_class
|
||||
class Stream: Equatable, Hashable, Identifiable {
|
||||
enum Resolution: String, CaseIterable, Comparable, Defaults.Serializable {
|
||||
case hd1440p60, hd1440p, hd1080p60, hd1080p, hd720p60, hd720p, sd480p, sd360p, sd240p, sd144p, unknown
|
||||
|
||||
var name: String {
|
||||
"\(height)p\(refreshRate != -1 ? ", \(refreshRate) fps" : "")"
|
||||
}
|
||||
|
||||
var height: Int {
|
||||
if self == .unknown {
|
||||
return -1
|
||||
}
|
||||
|
||||
let resolutionPart = rawValue.components(separatedBy: "p").first!
|
||||
return Int(resolutionPart.components(separatedBy: CharacterSet.decimalDigits.inverted).joined())!
|
||||
}
|
||||
|
||||
var refreshRate: Int {
|
||||
if self == .unknown {
|
||||
return -1
|
||||
}
|
||||
|
||||
let refreshRatePart = rawValue.components(separatedBy: "p")[1]
|
||||
return Int(refreshRatePart.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? -1
|
||||
}
|
||||
|
||||
static func from(resolution: String) -> Resolution {
|
||||
allCases.first { "\($0)".contains(resolution) } ?? .unknown
|
||||
}
|
||||
|
||||
static func < (lhs: Resolution, rhs: Resolution) -> Bool {
|
||||
lhs.height < rhs.height
|
||||
}
|
||||
}
|
||||
|
||||
enum Kind: String, Comparable {
|
||||
case stream, adaptive, hls
|
||||
|
||||
private var sortOrder: Int {
|
||||
switch self {
|
||||
case .hls:
|
||||
return 0
|
||||
case .stream:
|
||||
return 1
|
||||
case .adaptive:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
static func < (lhs: Kind, rhs: Kind) -> Bool {
|
||||
lhs.sortOrder < rhs.sortOrder
|
||||
}
|
||||
}
|
||||
|
||||
let id = UUID()
|
||||
|
||||
var instance: Instance!
|
||||
var audioAsset: AVURLAsset!
|
||||
var videoAsset: AVURLAsset!
|
||||
var hlsURL: URL!
|
||||
|
||||
var resolution: Resolution!
|
||||
var kind: Kind!
|
||||
|
||||
var encoding: String!
|
||||
|
||||
init(
|
||||
instance: Instance? = nil,
|
||||
audioAsset: AVURLAsset? = nil,
|
||||
videoAsset: AVURLAsset? = nil,
|
||||
hlsURL: URL? = nil,
|
||||
resolution: Resolution? = nil,
|
||||
kind: Kind = .hls,
|
||||
encoding: String? = nil
|
||||
) {
|
||||
self.instance = instance
|
||||
self.audioAsset = audioAsset
|
||||
self.videoAsset = videoAsset
|
||||
self.hlsURL = hlsURL
|
||||
self.resolution = resolution
|
||||
self.kind = kind
|
||||
self.encoding = encoding
|
||||
}
|
||||
|
||||
var quality: String {
|
||||
kind == .hls ? "adaptive (HLS)" : "\(resolution.name) \(kind == .stream ? "(\(kind.rawValue))" : "")"
|
||||
}
|
||||
|
||||
var description: String {
|
||||
"\(quality) - \(instance?.description ?? "")"
|
||||
}
|
||||
|
||||
var assets: [AVURLAsset] {
|
||||
[audioAsset, videoAsset]
|
||||
}
|
||||
|
||||
var videoAssetContainsAudio: Bool {
|
||||
assets.dropFirst().allSatisfy { $0.url == assets.first!.url }
|
||||
}
|
||||
|
||||
var singleAssetURL: URL? {
|
||||
if kind == .hls {
|
||||
return hlsURL
|
||||
} else if videoAssetContainsAudio {
|
||||
return videoAsset.url
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func == (lhs: Stream, rhs: Stream) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(videoAsset?.url)
|
||||
hasher.combine(audioAsset?.url)
|
||||
hasher.combine(hlsURL)
|
||||
}
|
||||
}
|
||||
60
Model/SubscriptionsModel.swift
Normal file
@@ -0,0 +1,60 @@
|
||||
import Foundation
|
||||
import Siesta
|
||||
import SwiftUI
|
||||
|
||||
final class SubscriptionsModel: ObservableObject {
|
||||
@Published var channels = [Channel]()
|
||||
var accounts: AccountsModel
|
||||
|
||||
var resource: Resource? {
|
||||
accounts.api.subscriptions
|
||||
}
|
||||
|
||||
init(accounts: AccountsModel? = nil) {
|
||||
self.accounts = accounts ?? AccountsModel()
|
||||
}
|
||||
|
||||
var all: [Channel] {
|
||||
channels.sorted { $0.name.lowercased() < $1.name.lowercased() }
|
||||
}
|
||||
|
||||
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 else {
|
||||
return
|
||||
}
|
||||
let request = force ? resource?.load() : resource?.loadIfNeeded()
|
||||
|
||||
request?
|
||||
.onSuccess { resource in
|
||||
if let channels: [Channel] = resource.typedContent() {
|
||||
self.channels = channels
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
.onFailure { _ in
|
||||
self.channels = []
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleLoad(onSuccess: @escaping () -> Void) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
self.load(force: true, onSuccess: onSuccess)
|
||||
}
|
||||
}
|
||||
}
|
||||
39
Model/Thumbnail.swift
Normal file
@@ -0,0 +1,39 @@
|
||||
import Foundation
|
||||
import SwiftyJSON
|
||||
|
||||
struct Thumbnail {
|
||||
enum Quality: String, CaseIterable {
|
||||
case maxres, maxresdefault, sddefault, high, medium, `default`, start, middle, end
|
||||
|
||||
var filename: String {
|
||||
switch self {
|
||||
case .maxres:
|
||||
return "maxres"
|
||||
case .maxresdefault:
|
||||
return "maxresdefault"
|
||||
case .sddefault:
|
||||
return "sddefault"
|
||||
case .high:
|
||||
return "hqdefault"
|
||||
case .medium:
|
||||
return "mqdefault"
|
||||
case .default:
|
||||
return "default"
|
||||
case .start:
|
||||
return "1"
|
||||
case .middle:
|
||||
return "2"
|
||||
case .end:
|
||||
return "3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var url: URL
|
||||
var quality: Quality
|
||||
|
||||
init(url: URL, quality: Quality) {
|
||||
self.url = url
|
||||
self.quality = quality
|
||||
}
|
||||
}
|
||||
30
Model/ThumbnailsModel.swift
Normal file
@@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
|
||||
final class ThumbnailsModel: ObservableObject {
|
||||
@Published var unloadable = Set<URL>()
|
||||
|
||||
func insertUnloadable(_ url: URL) {
|
||||
unloadable.insert(url)
|
||||
}
|
||||
|
||||
func isUnloadable(_ url: URL!) -> Bool {
|
||||
guard !url.isNil else {
|
||||
return true
|
||||
}
|
||||
|
||||
return unloadable.contains(url)
|
||||
}
|
||||
|
||||
func best(_ video: Video) -> URL? {
|
||||
let qualities = [Thumbnail.Quality.maxresdefault, .medium, .default]
|
||||
|
||||
for quality in qualities {
|
||||
let url = video.thumbnailURL(quality: quality)
|
||||
if !isUnloadable(url) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
21
Model/TrendingCategory.swift
Normal file
@@ -0,0 +1,21 @@
|
||||
import Defaults
|
||||
|
||||
enum TrendingCategory: String, CaseIterable, Identifiable, Defaults.Serializable {
|
||||
case `default`, music, gaming, movies
|
||||
|
||||
var id: RawValue {
|
||||
rawValue
|
||||
}
|
||||
|
||||
var title: RawValue {
|
||||
rawValue.capitalized
|
||||
}
|
||||
|
||||
var name: String {
|
||||
id == "default" ? "Trending" : title
|
||||
}
|
||||
|
||||
var controlLabel: String {
|
||||
id == "default" ? "All" : title
|
||||
}
|
||||
}
|
||||
114
Model/Video.swift
Normal file
@@ -0,0 +1,114 @@
|
||||
import Alamofire
|
||||
import AVKit
|
||||
import Foundation
|
||||
import SwiftyJSON
|
||||
|
||||
struct Video: Identifiable, Equatable, Hashable {
|
||||
let id: String
|
||||
let videoID: String
|
||||
var title: String
|
||||
var thumbnails: [Thumbnail]
|
||||
var author: String
|
||||
var length: TimeInterval
|
||||
var published: String
|
||||
var views: Int
|
||||
var description: String?
|
||||
var genre: String?
|
||||
|
||||
// index used when in the Playlist
|
||||
let indexID: String?
|
||||
|
||||
var live: Bool
|
||||
var upcoming: Bool
|
||||
|
||||
var streams = [Stream]()
|
||||
|
||||
var publishedAt: Date?
|
||||
var likes: Int?
|
||||
var dislikes: Int?
|
||||
var keywords = [String]()
|
||||
|
||||
var channel: Channel
|
||||
|
||||
var related = [Video]()
|
||||
|
||||
init(
|
||||
id: String? = nil,
|
||||
videoID: String,
|
||||
title: String,
|
||||
author: String,
|
||||
length: TimeInterval,
|
||||
published: String,
|
||||
views: Int,
|
||||
description: String? = nil,
|
||||
genre: String? = nil,
|
||||
channel: Channel,
|
||||
thumbnails: [Thumbnail] = [],
|
||||
indexID: String? = nil,
|
||||
live: Bool = false,
|
||||
upcoming: Bool = false,
|
||||
publishedAt: Date? = nil,
|
||||
likes: Int? = nil,
|
||||
dislikes: Int? = nil,
|
||||
keywords: [String] = [],
|
||||
streams: [Stream] = [],
|
||||
related: [Video] = []
|
||||
) {
|
||||
self.id = id ?? UUID().uuidString
|
||||
self.videoID = videoID
|
||||
self.title = title
|
||||
self.author = author
|
||||
self.length = length
|
||||
self.published = published
|
||||
self.views = views
|
||||
self.description = description
|
||||
self.genre = genre
|
||||
self.channel = channel
|
||||
self.thumbnails = thumbnails
|
||||
self.indexID = indexID
|
||||
self.live = live
|
||||
self.upcoming = upcoming
|
||||
self.publishedAt = publishedAt
|
||||
self.likes = likes
|
||||
self.dislikes = dislikes
|
||||
self.keywords = keywords
|
||||
self.streams = streams
|
||||
self.related = related
|
||||
}
|
||||
|
||||
var publishedDate: String? {
|
||||
(published.isEmpty || published == "0 seconds ago") ? nil : published
|
||||
}
|
||||
|
||||
var viewsCount: String? {
|
||||
views != 0 ? views.formattedAsAbbreviation() : nil
|
||||
}
|
||||
|
||||
var likesCount: String? {
|
||||
guard likes != -1 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return likes?.formattedAsAbbreviation()
|
||||
}
|
||||
|
||||
var dislikesCount: String? {
|
||||
guard dislikes != -1 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return dislikes?.formattedAsAbbreviation()
|
||||
}
|
||||
|
||||
func thumbnailURL(quality: Thumbnail.Quality) -> URL? {
|
||||
thumbnails.first { $0.quality == quality }?.url
|
||||
}
|
||||
|
||||
static func == (lhs: Video, rhs: Video) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
13
Open in Yattee/Info.plist
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.Safari.web-extension</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SafariWebExtensionHandler</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
10
Open in Yattee/Open in Yattee.entitlements
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
Open in Yattee/Resources/images/icon-128.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
Open in Yattee/Resources/images/icon-256.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
Open in Yattee/Resources/images/icon-48.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
Open in Yattee/Resources/images/icon-512.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
Open in Yattee/Resources/images/icon-64.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
Open in Yattee/Resources/images/icon-96.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
Open in Yattee/Resources/images/toolbar-icon-16.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
Open in Yattee/Resources/images/toolbar-icon-19.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
Open in Yattee/Resources/images/toolbar-icon-32.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
Open in Yattee/Resources/images/toolbar-icon-38.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
31
Open in Yattee/Resources/manifest.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"default_locale": "en",
|
||||
|
||||
"name": "Open in Yattee",
|
||||
"description": "Open YouTube videos in Yattee app",
|
||||
"version": "1.0",
|
||||
|
||||
"icons": {
|
||||
"48": "images/icon-48.png",
|
||||
"96": "images/icon-96.png",
|
||||
"128": "images/icon-128.png",
|
||||
"256": "images/icon-256.png",
|
||||
"512": "images/icon-512.png",
|
||||
|
||||
"16": "images/toolbar-icon-16.png",
|
||||
"19": "images/toolbar-icon-19.png",
|
||||
"32": "images/toolbar-icon-32.png",
|
||||
"38": "images/toolbar-icon-38.png"
|
||||
},
|
||||
"content_scripts": [{
|
||||
"js": [ "content.js" ],
|
||||
"matches": [
|
||||
"*://*.youtube-nocookie.com/*",
|
||||
"*://*.youtube.com/*",
|
||||
"*://*.youtu.be/*"
|
||||
]
|
||||
}],
|
||||
|
||||
"permissions": [ ]
|
||||
}
|
||||
15
Open in Yattee/SafariWebExtensionHandler.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
import os.log
|
||||
import SafariServices
|
||||
|
||||
final class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
|
||||
func beginRequest(with context: NSExtensionContext) {
|
||||
let item = context.inputItems[0] as! NSExtensionItem
|
||||
let message = item.userInfo?[SFExtensionMessageKey]
|
||||
os_log(.default, "Received message from browser.runtime.sendNativeMessage: %@", message as! CVarArg)
|
||||
|
||||
let response = NSExtensionItem()
|
||||
response.userInfo = [SFExtensionMessageKey: ["Response to": message]]
|
||||
|
||||
context.completeRequest(returningItems: [response], completionHandler: nil)
|
||||
}
|
||||
}
|
||||
26
Open in Yattee/content.js
Normal file
@@ -0,0 +1,26 @@
|
||||
if (document.readyState !== 'complete') {
|
||||
window.addEventListener('load', redirectAndReplaceContentWithLink);
|
||||
} else {
|
||||
redirectAndReplaceContentWithLink();
|
||||
}
|
||||
|
||||
function yatteeUrl() {
|
||||
return window.location.href.replace(/^https?:\/\//, 'yattee://');
|
||||
}
|
||||
|
||||
function yatteeLink() {
|
||||
return '<a href="'+ yatteeUrl() +'" onclick=\'window.location.href="'+ yatteeUrl() +'"\'>Open in Yattee</a>';
|
||||
}
|
||||
|
||||
function redirect() {
|
||||
window.location.href = yatteeUrl()
|
||||
}
|
||||
|
||||
function replaceContentWithLink() {
|
||||
document.querySelector('body').innerHTML = '<h1>' + yatteeLink() + '</h1>';
|
||||
}
|
||||
|
||||
function redirectAndReplaceContentWithLink(){
|
||||
redirect()
|
||||
replaceContentWithLink()
|
||||
}
|
||||
187
README.md
@@ -1,100 +1,143 @@
|
||||
<div align="center">
|
||||
<!-- TODO: new logo asset -->
|
||||
<img src="https://r.yattee.stream/icons/yattee-150.png" width="150" height="150" alt="Yattee logo">
|
||||
<h1>Yattee</h1>
|
||||
<p>Privacy-focused video player for iOS, macOS, and tvOS</p>
|
||||

|
||||
|
||||
Video player for [Invidious](https://github.com/iv-org/invidious) and [Piped](https://github.com/TeamPiped/Piped) instances built for iOS, tvOS and macOS.
|
||||
|
||||
|
||||
[](https://www.gnu.org/licenses/agpl-3.0.en.html)
|
||||
[](https://github.com/yattee/yattee/issues)
|
||||
[](https://github.com/yattee/yattee/pulls)
|
||||
[](https://matrix.to/#/#Yattee:matrix.org)
|
||||
[](https://matrix.to/#/#yattee:matrix.org)
|
||||
|
||||
[](https://yattee.stream/discord)
|
||||
|
||||
<!-- TODO: new screenshot assets -->
|
||||

|
||||
</div>
|
||||
|
||||
## Features
|
||||
* Native user interface built with [SwiftUI](https://developer.apple.com/xcode/swiftui/)
|
||||
* Multiple instances and accounts, fast switching
|
||||
* [SponsorBlock](https://sponsor.ajay.app/), configurable categories to skip
|
||||
* Player queue and history
|
||||
* Fullscreen playback, Picture in Picture and AirPlay support
|
||||
* Stream quality selection
|
||||
* Favorites: customizable section of channels, playlists, trending, searches and other views
|
||||
* `yattee://` URL Scheme for integrations
|
||||
|
||||
**Playback**
|
||||
- 4K video with custom MPV-based player
|
||||
- Picture in Picture, background audio, fullscreen
|
||||
- Playback queue, history, resume from last position
|
||||
- Chapter navigation, playback speed, subtitles and captions
|
||||
- Gesture controls (seek, volume, brightness)
|
||||
### Availability
|
||||
| Feature | Invidious | Piped |
|
||||
| - | - | - |
|
||||
| User Accounts | ✅ | ✅ |
|
||||
| Subscriptions | ✅ | ✅ |
|
||||
| Popular | ✅ | 🔴 |
|
||||
| User Playlists | ✅ | 🔴 |
|
||||
| Trending | ✅ | ✅ |
|
||||
| Channels | ✅ | ✅ |
|
||||
| Channel Playlists | ✅ | ✅ |
|
||||
| Search | ✅ | ✅ |
|
||||
| Search Suggestions | ✅ | ✅ |
|
||||
| Search Filters | ✅ | 🔴 |
|
||||
| Subtitles | 🔴 | ✅ |
|
||||
|
||||
**Content Sources**
|
||||
- YouTube via Invidious, Piped, or self-hosted Yattee Server
|
||||
- PeerTube instances (federated video)
|
||||
- Local files, SMB network shares, WebDAV servers
|
||||
## Installation
|
||||
### Requirements
|
||||
System requirements:
|
||||
* iOS 14 (or newer)
|
||||
* tvOS 15 (or newer)
|
||||
* macOS Big Sur (or newer)
|
||||
|
||||
**Integrations**
|
||||
- [SponsorBlock](https://sponsor.ajay.app/) (configurable skip categories)
|
||||
- [DeArrow](https://dearrow.ajay.app/) (crowdsourced titles and thumbnails)
|
||||
- [Return YouTube Dislike](https://returnyoutubedislike.com/)
|
||||
### How to install?
|
||||
#### [AltStore](https://altstore.io/) (free)
|
||||
You can sideload IPA files downloaded from the [Releases](https://github.com/yattee/yattee/releases) page to your iOS or tvOS device - check [AltStore FAQ](https://altstore.io/faq/) for more information.
|
||||
|
||||
**Privacy**
|
||||
- No tracking, no ads, no account required
|
||||
- All traffic goes through your chosen instances
|
||||
If you have to access to the beta AltStore version (v1.5, for Patreons only), you can add the following repository in `Browse > Sources` screen:
|
||||
|
||||
**Library**
|
||||
- Subscriptions with per-channel notifications
|
||||
- Bookmarks, playlists, watch history
|
||||
- Search across all configured sources
|
||||
- Import/export subscriptions (JSON, CSV, OPML)
|
||||
`https://alt.yattee.stream`
|
||||
|
||||
**Downloads & Sync**
|
||||
- Offline video and audio downloads
|
||||
- iCloud sync for bookmarks, subscriptions, and history across devices
|
||||
- Handoff continuity between iPhone, iPad, Mac, and Apple TV
|
||||
#### Signing IPA files online (paid)
|
||||
[UDID Registrations](https://www.udidregistrations.com/) provides services to sign IPA files for your devices. Refer to: ***Break free from the App Store*** section of the website for more information.
|
||||
|
||||
**Platforms**
|
||||
- iOS 18+ / macOS 15+ / tvOS 18+
|
||||
- Native SwiftUI on every platform
|
||||
- Customizable home layout, accent colors, and player controls
|
||||
#### Manual installation
|
||||
Download sources and compile them on a Mac using Xcode, install to your devices. Please note that if you are not registered in Apple Developer Program you will need to reinstall every 7 days.
|
||||
|
||||
## Yattee Server
|
||||
## Integrations
|
||||
### macOS
|
||||
With [Finicky](https://github.com/johnste/finicky) you can configure your system to open all the video links in the app. Example configuration:
|
||||
```js
|
||||
{
|
||||
match: [
|
||||
finicky.matchDomains(/(.*\.)?youtube.com/),
|
||||
finicky.matchDomains(/(.*\.)?youtu.be/)
|
||||
],
|
||||
browser: "/Applications/Yattee.app"
|
||||
}
|
||||
```
|
||||
|
||||
A self-hosted backend powered by [yt-dlp](https://github.com/yt-dlp/yt-dlp) that gives Yattee superpowers.
|
||||
## Screenshots
|
||||
### iOS
|
||||
| Player | Search | Playlists |
|
||||
| - | - | - |
|
||||
| [](https://r.yattee.stream/screenshots/iOS/player.png) | [](https://r.yattee.stream/screenshots/iOS/search-suggestions.png) | [](https://r.yattee.stream/screenshots/iOS/playlists.png) |
|
||||
### iPadOS
|
||||
| Settings | Player | Subscriptions |
|
||||
| - | - | - |
|
||||
| [](https://r.yattee.stream/screenshots/iPadOS/settings.png) | [](https://r.yattee.stream/screenshots/iPadOS/player.png) | [](https://r.yattee.stream/screenshots/iPadOS/subscriptions.png) |
|
||||
### tvOS
|
||||
| Player | Popular | Search | Now Playing | Settings |
|
||||
| - | - | - | - | - |
|
||||
| [](https://r.yattee.stream/screenshots/tvOS/player.png) | [](https://r.yattee.stream/screenshots/tvOS/popular.png) | [](https://r.yattee.stream/screenshots/tvOS/search.png) | [](https://r.yattee.stream/screenshots/tvOS/now-playing.png) | [](https://r.yattee.stream/screenshots/tvOS/settings.png) |
|
||||
### macOS
|
||||
| Player | Channel | Search | Settings |
|
||||
| - | - | - | - |
|
||||
| [](https://r.yattee.stream/screenshots/macOS/player.png) | [](https://r.yattee.stream/screenshots/macOS/channel.png) | [](https://r.yattee.stream/screenshots/macOS/search.png) | [](https://r.yattee.stream/screenshots/macOS/settings.png) |
|
||||
|
||||
- **Direct stream URLs** — gets fresh YouTube CDN URLs, bypassing Invidious/Piped blocks and rate limits
|
||||
- **Play from 1000+ sites** — Vimeo, TikTok, Twitch, Dailymotion, Twitter/X, and anything else yt-dlp supports
|
||||
- **Invidious-compatible API** — drop-in replacement, works alongside existing Invidious/Piped instances
|
||||
- **Self-hosted & private** — run on your own hardware, no data leaves your network
|
||||
- **Fast parallel streaming** — yt-dlp parallel downloading streams video while it downloads
|
||||
- **Admin panel** — web UI for settings, credentials, and monitoring
|
||||
- **Docker ready** — single container deployment
|
||||
## Tips
|
||||
### Settings
|
||||
* [tvOS] To open settings, press Play/Pause button while hovering over navigation menu or video
|
||||
### Navigation
|
||||
* Use videos context menus to add to queue, open or subscribe channel and add to playlist
|
||||
* [tvOS] Pressing buttons in the app trigger switch to next available option (for example: next account in Settings). If you want to access list of all options, press and hold to open the context menu.
|
||||
* [iOS] Swipe the player/title bar: up to open fullscreen details view, bottom to close fullscreen details or hide player
|
||||
### Favorites
|
||||
* Add more sections using ❤️ button in views channels, playlists, searches, subscriptions and popular
|
||||
* [iOS/macOS] Reorganize with dragging and dropping
|
||||
* [iOS/macOS] Remove section with right click/press and hold on section name
|
||||
* [tvOS] Reorganize and remove from `Settings > Edit Favorites...`
|
||||
### Keyboard shortcuts
|
||||
* `Command+1` - Favorites
|
||||
* `Command+2` - Subscriptions
|
||||
* `Command+3` - Popular
|
||||
* `Command+4` - Trending
|
||||
* `Command+F` - Search
|
||||
* `Command+P` - Play/Pause
|
||||
* `Command+S` - Play Next
|
||||
* `Command+O` - Toggle Player
|
||||
|
||||
Check out the [yattee-server](https://github.com/yattee/yattee-server) repository to get started.
|
||||
|
||||
## Documentation
|
||||
## Donations
|
||||
|
||||
- [Installation](https://github.com/yattee/yattee/wiki/Installation-Instructions)
|
||||
- [Building](https://github.com/yattee/yattee/wiki/Building-instructions)
|
||||
- [Features](https://github.com/yattee/yattee/wiki/Features)
|
||||
- [FAQ](https://github.com/yattee/yattee/wiki/FAQ)
|
||||
- [Screenshots Gallery](https://github.com/yattee/yattee/wiki/Screenshots-Gallery)
|
||||
- [Tips](https://github.com/yattee/yattee/wiki/Tips)
|
||||
- [Integrations](https://github.com/yattee/yattee/wiki/Integrations)
|
||||
- [Donations](https://github.com/yattee/yattee/wiki/Donations)
|
||||
You can support development of this app with
|
||||
[Patreon](https://www.patreon.com/arekf) or cryptocurrencies:
|
||||
|
||||
**Monero (XMR)**
|
||||
```
|
||||
48zfKjLmnXs21PinU2ucMiUPwhiKt5d7WJKiy3ACVS28BKqSn52c1TX8L337oESHJ5TZCyGkozjfWZG11h6C46mN9n4NPrD
|
||||
```
|
||||
**Bitcoin (BTC)**
|
||||
```
|
||||
bc1qe24zz5a5hm0trc7glwckz93py274eycxzju3mv
|
||||
```
|
||||
**Ethereum (ETH)**
|
||||
```
|
||||
0xa2f81A58Ec5E550132F03615c8d91954A4E37423
|
||||
```
|
||||
|
||||
Donations will be used to cover development program access and domain renewal costs.
|
||||
|
||||
## Contributing
|
||||
If you're interestred in contributing, you can browse the [issues](https://github.com/yattee/yattee/issues) list or create a new one to discuss your feature idea. Every contribution is very welcome.
|
||||
|
||||
Browse the [issues](https://github.com/yattee/yattee/issues) list or open a new one to discuss your idea. Every contribution is welcome.
|
||||
## License and Liability
|
||||
|
||||
Join [Discord](https://yattee.stream/discord) or the [Matrix channel](https://matrix.to/#/#yattee:matrix.org) if you need advice or want to discuss the project.
|
||||
Yattee and its components is shared on [AGPL v3](https://www.gnu.org/licenses/agpl-3.0.en.html) license.
|
||||
|
||||
## Translations
|
||||
Contributors take no responsibility for the use of the tool (Point 16. of the license). We strongly recommend you abide by the valid official regulations in your country. Furthermore, we refuse liability for any inappropriate use of the tool, such as downloading materials without proper consent.
|
||||
|
||||
Help make Yattee accessible to everyone by contributing translations.
|
||||
|
||||
<a href="https://hosted.weblate.org/engage/yattee/">
|
||||
<img src="https://hosted.weblate.org/widgets/yattee/-/localizable-strings/multi-auto.svg" alt="Translation status" />
|
||||
</a>
|
||||
|
||||
Localization hosting provided by [Weblate](https://weblate.org/en/).
|
||||
|
||||
## License
|
||||
|
||||
Yattee is shared under the [AGPL v3](https://www.gnu.org/licenses/agpl-3.0.en.html) license.
|
||||
This tool is an open source software built for learning and research purposes.
|
||||
|
||||
38
Shared/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "display-p3",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.361",
|
||||
"green" : "0.200",
|
||||
"red" : "0.129"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "display-p3",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.263",
|
||||
"green" : "0.290",
|
||||
"red" : "0.859"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||