Compare commits

..

40 Commits

Author SHA1 Message Date
github-actions[bot]
d1a0adbea0 Bump build number to 252 2026-02-13 19:50:41 +00:00
Arkadiusz Fal
02c0b637cb Fix build number file path and remove update_fastlane in CI
- Write latest_build_number.txt to repo root using explicit path
  (fastlane runs from fastlane/ subdir, so relative path was wrong)
- Remove update_fastlane from before_all to avoid CI instability
2026-02-13 20:46:21 +01:00
Arkadiusz Fal
49b8599a67 Fix release workflow by updating Ruby from 3.1 to 3.4
Ruby 3.1 is EOL and bundle install fails with exit code 5 on
latest macOS runners. Updated to match .ruby-version (3.4.8).
2026-02-13 20:40:40 +01:00
Arkadiusz Fal
8379006630 Update CHANGELOG 2026-02-13 20:21:24 +01:00
Arkadiusz Fal
6855210608 Fix release workflow to support non-main branches 2026-02-13 20:16:04 +01:00
Arkadiusz Fal
c339597f8a Add git-cliff based changelog generator
Bash wrapper around git-cliff with cliff.toml config for regex-based
commit parsers handling skip patterns and category matching.
2026-02-13 20:09:49 +01:00
Arkadiusz Fal
79e4a24f75 Auto-increment build number in release workflow
Query App Store Connect for the latest TestFlight build number across
all platforms (iOS, tvOS, macOS) and auto-increment it, eliminating
the need for the separate bump-build workflow.
2026-02-13 19:18:42 +01:00
Arkadiusz Fal
b559886e2e Update localizable 2026-02-13 18:59:47 +01:00
Arkadiusz Fal
9587ab37f8 Add DEV badge on iCloud settings for debug builds
Shows an orange "DEV" capsule next to the iCloud row in Settings and a
development environment notice at the top of iCloud settings, helping
distinguish CloudKit dev environment from production during development.
2026-02-13 18:14:19 +01:00
Arkadiusz Fal
3aadc9be70 Fix deleted playlists resurrecting from iCloud after app restart
Pending deletes were lost across app restarts because
recoverPersistedPendingChanges() never reconstructed CKRecord.ID
objects from persisted record names. Additionally, incoming iCloud
records for deleted playlists were blindly applied, and orphaned
playlist items in CloudKit would recreate placeholder playlists.

- Rebuild pendingDeletes array from UserDefaults on recovery
- Guard applyRemoteRecord against records pending local deletion
- Skip deferred items whose parent playlist is pending deletion
- Queue all playlist item deletions when deleting a playlist
- Clean up placeholder playlists for pending-delete playlists
2026-02-13 18:05:30 +01:00
Arkadiusz Fal
e51ebd7ab2 Fix feed channel filter avatars showing placeholders instead of images
The filter strip was passing the Invidious instance URL as serverURL to
AvatarURLBuilder, which built a Yattee Server-style /avatar/ path that
doesn't exist on Invidious. Now passes the actual Yattee Server URL
(matching SubscriptionsView pattern) and enriches channels from
CachedChannelData as a fallback when the API doesn't return thumbnails.
2026-02-13 07:04:40 +01:00
Arkadiusz Fal
9eb6812fb2 Bump build number to 251 2026-02-13 08:43:58 +01:00
Arkadiusz Fal
089e5842de Fix build number 2026-02-13 08:43:58 +01:00
Arkadiusz Fal
bce72d776c Add Fastlane config and update release workflow for v2
Single unified "Yattee" scheme replaces per-platform schemes.
Release workflow now has toggleable platform inputs instead of
matrix strategy. Standalone mac notarized workflow removed in
favor of the build_mac_notarized toggle. Share extension bundle
ID updated from Open-in-Yattee to ShareExtension.
2026-02-13 08:43:58 +01:00
Arkadiusz Fal
b54e0440ec Change default layout settings 2026-02-12 08:58:44 +01:00
Arkadiusz Fal
56ac6d8848 Update media browser view options sheet layout 2026-02-12 08:51:54 +01:00
Arkadiusz Fal
bcbd8f3350 Persist media browser view options per source
Save sort order, sort direction, and show-only-playable filter to
UserDefaults keyed by source ID so preferences survive navigation.
2026-02-12 08:30:45 +01:00
Arkadiusz Fal
a8c59e61cd Move close video button from toolbar into now playing card in RemoteControlView 2026-02-12 08:13:17 +01:00
Arkadiusz Fal
4f954b7c9c Add Enable All / Disable All menu to channel notifications settings 2026-02-12 07:55:56 +01:00
Arkadiusz Fal
0cfe365d4f Show video thumbnail in mini player during PiP
When PiP is active, the MPV render view shows a black frame since
rendering goes to the PiP sample buffer layer. Overlay the video
thumbnail (preferring DeArrow) on top to cover the black area,
fading it in/out smoothly when PiP starts/stops.
2026-02-12 07:19:30 +01:00
Arkadiusz Fal
284aec679f Add context menu and swipe actions to related videos in VideoInfoView 2026-02-12 07:01:08 +01:00
Arkadiusz Fal
d8d16280ff Fix Invidious login failing for passwords with special characters
Use URLComponents/URLQueryItem for standard form-URL encoding instead
of manual percent-encoding with CharacterSet.alphanumerics, which
included non-ASCII Unicode letters and had an unsafe raw-value fallback.
2026-02-12 06:04:16 +01:00
Arkadiusz Fal
d546c0a976 Fix subscriber count layout shift in VideoInfoView channel row 2026-02-12 06:00:14 +01:00
Arkadiusz Fal
fd41833532 Persist author cache to disk for instant channel info across restarts
Back the in-memory authorCache with a JSON file in ~/Library/Caches/AuthorCache/.
Disk is lazy-loaded on first lookup and saved asynchronously on each cache update.
Capped at 500 entries to prevent unbounded growth.

- Cache author data from video detail API responses (PlayerService, VideoInfoView)
- Replace ChannelView's private CachedChannelHeader with shared CachedChannelData
- Enrich author with cached avatar/subscriber count in VideoChannelRow, TVDetailsPanel, VideoInfoView
2026-02-12 05:47:43 +01:00
Arkadiusz Fal
d1d7edb5ec Fix Feed tab flashing ContentUnavailableView on initial load
When a cancelled load task fell through to `isLoading = false`, it
created a 1-frame gap where the empty view rendered before the
replacement task set `isLoading` back to `true`. Return early on
cancellation so the surviving task controls loading state.
2026-02-12 05:17:25 +01:00
Arkadiusz Fal
24e60916f8 Fix blurred background gradient not using DeArrow thumbnail 2026-02-12 05:08:22 +01:00
Arkadiusz Fal
cd2e0a6e81 Fix playlist rows in ChannelView not tappable in empty space 2026-02-12 05:04:40 +01:00
Arkadiusz Fal
ded130a8c3 Fix lock screen always showing 10s seek regardless of system controls setting 2026-02-12 05:02:41 +01:00
Arkadiusz Fal
78bc7e0063 Remove excessive logging 2026-02-12 04:44:16 +01:00
Arkadiusz Fal
6c30e745d9 Fix player dismiss gesture stuck after panel dismiss with comments expanded
Reset isCommentsExpanded and commentsFrame on the NavigationCoordinator
directly when the portrait panel is dismissed, since PortraitDetailsPanel
owns its own @State that doesn't sync back through .onChange during dismiss.
Also track comments overlay frame via GeometryReader so the dismiss gesture
can allow swipes outside the comments area instead of blanket-blocking.
2026-02-12 04:42:32 +01:00
Arkadiusz Fal
b6b6d280e1 Fix incomplete playlist loading by paginating through all pages
Playlists only loaded the first page of videos. Add full pagination for
both Invidious and Piped playlist endpoints (public and authenticated).
Deduplicate Invidious results by playlist index to handle its overlapping
page windows. Also fix URL encoding in Invidious login to use strict
form-encoding charset.
2026-02-12 02:02:32 +01:00
Arkadiusz Fal
7ac45b46a3 Fix pull-to-refresh scroll offset not resetting in InstanceBrowseView
Move .refreshable from the outer GeometryReader onto the ScrollView
itself so SwiftUI can properly coordinate the scroll offset bounce-back.
The ScrollView was inside an .overlay() which doesn't participate in
the parent's layout system, breaking the offset reset.

Closes #917
2026-02-12 01:21:54 +01:00
Arkadiusz Fal
288113d177 Fix URL scheme UI tests for YouTube deep links and content loading
Route HTTPS YouTube URLs through yattee://open?url= scheme since simctl
can't trigger Universal Links. Improve wait strategies: use player
expansion check for video tests, tree length threshold for channel/
playlist content loading. Add retry logic to cleanup_after_video.
2026-02-10 06:21:10 +01:00
Arkadiusz Fal
cae1226cfe Add URL scheme UI tests for deep link navigation
Test yattee:// custom scheme URLs navigate to correct screens:
playlists, bookmarks, history, downloads, channels, subscriptions,
continue-watching, and search. Handles iOS system confirmation dialog
via coordinate taps since it's invisible to AXe. Settings deep link
is excluded (known app bug - doesn't render when pushed to nav stack).
2026-02-10 05:45:19 +01:00
Arkadiusz Fal
9cd9506dcf Fix UI tests for onboarding flow and AddRemoteServer redesign
- Skip onboarding in tests by setting UserDefaults before launch
- Update all addSource.* identifiers to addRemoteServer.* for new flow
- Switch from identifier-based to text-based element lookups (iOS 26 AXe limitation)
- Add Yattee Server credential support in instance setup
- Update baseline screenshots for Home tab and settings
2026-02-10 00:05:39 +01:00
Arkadiusz Fal
3905fd8b18 Add AltStore source and separate update workflow from release pipeline
- Create standalone update-altstore.yml workflow (workflow_dispatch + workflow_call)
  that gets version from latest GitHub release tag instead of project.pbxproj
- Replace inline update_altstore job in release.yml with workflow_call reference
- Add altstore-source.json with app metadata and initial version entry
- Update README with revised features, TestFlight install link, and new logo assets
2026-02-09 21:37:39 +01:00
Arkadiusz Fal
13f7a0f0ca Fix panscan zoom pushing controls off screen for portrait videos 2026-02-09 08:22:16 +01:00
Arkadiusz Fal
612dce6b9f Refactor views 2026-02-09 01:13:02 +01:00
Arkadiusz Fal
8464464199 Fix locales 2026-02-09 00:13:46 +01:00
Arkadiusz Fal
05f921d605 Yattee v2 rewrite 2026-02-08 18:33:56 +01:00
369 changed files with 8629 additions and 31796 deletions

View File

@@ -9,22 +9,15 @@ on:
build_tvos:
description: 'Build tvOS (TestFlight)'
type: boolean
default: true
default: false
build_mac_beta:
description: 'Build macOS (TestFlight)'
type: boolean
default: true
default: false
build_mac_notarized:
description: 'Build macOS (notarized Developer ID + Sparkle appcast)'
description: 'Build macOS (notarized)'
type: boolean
default: true
release_channel:
description: 'Sparkle / Developer ID channel (also toggles GitHub prerelease flag)'
type: choice
options:
- beta
- stable
default: beta
default: false
create_release:
description: 'Create GitHub release'
type: boolean
@@ -49,11 +42,12 @@ env:
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:
determine_build_number:
name: Determine build number
runs-on: macos-26
runs-on: macos-latest
outputs:
build_number: ${{ steps.calc.outputs.build_number }}
version_number: ${{ steps.version.outputs.version_number }}
@@ -83,7 +77,7 @@ jobs:
if: ${{ inputs.build_ios }}
needs: [determine_build_number]
name: Release iOS to TestFlight
runs-on: macos-26
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
@@ -91,6 +85,9 @@ jobs:
ruby-version: '3.4'
bundler-cache: true
cache-version: 1
- name: Set signing to manual for CI
run: |
sed -i '' 's/CODE_SIGN_STYLE = Automatic/CODE_SIGN_STYLE = Manual/' Yattee.xcodeproj/project.pbxproj
- name: Set build number
run: |
sed -i '' 's/CURRENT_PROJECT_VERSION = [0-9]*/CURRENT_PROJECT_VERSION = ${{ needs.determine_build_number.outputs.build_number }}/' Yattee.xcodeproj/project.pbxproj
@@ -109,7 +106,7 @@ jobs:
if: ${{ inputs.build_tvos }}
needs: [determine_build_number]
name: Release tvOS to TestFlight
runs-on: macos-26
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
@@ -117,6 +114,9 @@ jobs:
ruby-version: '3.4'
bundler-cache: true
cache-version: 1
- name: Set signing to manual for CI
run: |
sed -i '' 's/CODE_SIGN_STYLE = Automatic/CODE_SIGN_STYLE = Manual/' Yattee.xcodeproj/project.pbxproj
- name: Set build number
run: |
sed -i '' 's/CURRENT_PROJECT_VERSION = [0-9]*/CURRENT_PROJECT_VERSION = ${{ needs.determine_build_number.outputs.build_number }}/' Yattee.xcodeproj/project.pbxproj
@@ -135,7 +135,7 @@ jobs:
if: ${{ inputs.build_mac_beta }}
needs: [determine_build_number]
name: Release macOS to TestFlight
runs-on: macos-26
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
@@ -143,6 +143,9 @@ jobs:
ruby-version: '3.4'
bundler-cache: true
cache-version: 1
- name: Set signing to manual for CI
run: |
sed -i '' 's/CODE_SIGN_STYLE = Automatic/CODE_SIGN_STYLE = Manual/' Yattee.xcodeproj/project.pbxproj
- name: Set build number
run: |
sed -i '' 's/CURRENT_PROJECT_VERSION = [0-9]*/CURRENT_PROJECT_VERSION = ${{ needs.determine_build_number.outputs.build_number }}/' Yattee.xcodeproj/project.pbxproj
@@ -161,7 +164,7 @@ jobs:
if: ${{ inputs.build_mac_notarized }}
needs: [determine_build_number]
name: Build and notarize macOS app
runs-on: macos-26
runs-on: macos-latest
env:
BUILD_NUMBER: ${{ needs.determine_build_number.outputs.build_number }}
VERSION_NUMBER: ${{ needs.determine_build_number.outputs.version_number }}
@@ -172,6 +175,9 @@ jobs:
ruby-version: '3.4'
bundler-cache: true
cache-version: 1
- name: Set signing to manual with Developer ID
run: |
sed -i '' 's/CODE_SIGN_STYLE = Automatic/CODE_SIGN_STYLE = Manual/' Yattee.xcodeproj/project.pbxproj
- name: Set build number
run: |
sed -i '' 's/CURRENT_PROJECT_VERSION = [0-9]*/CURRENT_PROJECT_VERSION = ${{ env.BUILD_NUMBER }}/' Yattee.xcodeproj/project.pbxproj
@@ -180,176 +186,50 @@ jobs:
- uses: maierj/fastlane-action@v3.0.0
with:
lane: mac build_and_notarize
- name: Resolve artifact paths
run: |
DIR="fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS"
echo "APP_PATH=$DIR/Yattee.app" >> $GITHUB_ENV
echo "ZIP_PATH=$DIR/Yattee-${{ env.VERSION_NUMBER }}-macOS.zip" >> $GITHUB_ENV
echo "DMG_PATH=$DIR/Yattee-${{ env.VERSION_NUMBER }}-macOS.dmg" >> $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 }}
${{ env.DMG_PATH }}
path: ${{ env.ZIP_PATH }}
if-no-files-found: error
release:
if: ${{ inputs.create_release && !cancelled() && !failure() }}
if: ${{ inputs.create_release && !cancelled() }}
needs: [determine_build_number, ios_beta, tvos_beta, mac_beta, mac_notarized]
name: Create GitHub release
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
tag: ${{ steps.compute_tag.outputs.tag }}
env:
BUILD_NUMBER: ${{ needs.determine_build_number.outputs.build_number }}
VERSION_NUMBER: ${{ needs.determine_build_number.outputs.version_number }}
RELEASE_CHANNEL: ${{ inputs.release_channel }}
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.REPO_TOKEN }}
token: ${{ secrets.GIT_AUTHORIZATION }}
- name: Commit build number
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
sed -i 's/CURRENT_PROJECT_VERSION = [0-9]*/CURRENT_PROJECT_VERSION = ${{ env.BUILD_NUMBER }}/' Yattee.xcodeproj/project.pbxproj
git add Yattee.xcodeproj/project.pbxproj
git diff --cached --quiet && echo "Build number already up to date" || {
git commit -m "Bump build number to ${{ env.BUILD_NUMBER }}"
git push origin ${{ github.ref_name }}
}
- uses: actions/download-artifact@v4
with:
path: artifacts
- name: Compute release tag
id: compute_tag
run: |
if [ "$RELEASE_CHANNEL" = "beta" ]; then
echo "tag=${VERSION_NUMBER}-beta.${BUILD_NUMBER}" >> "$GITHUB_OUTPUT"
echo "prerelease=true" >> "$GITHUB_OUTPUT"
else
echo "tag=${VERSION_NUMBER}-${BUILD_NUMBER}" >> "$GITHUB_OUTPUT"
echo "prerelease=false" >> "$GITHUB_OUTPUT"
fi
- uses: ncipollo/release-action@v1
with:
# No .pkg here on purpose: the mac TestFlight pkg is App Store-signed
# (no Sparkle, not notarized) and would not run if installed directly.
artifacts: artifacts/**/*.ipa,artifacts/**/*.zip,artifacts/**/*.dmg
artifacts: artifacts/**/*.ipa,artifacts/**/*.zip,artifacts/**/*.pkg
commit: ${{ github.ref_name }}
tag: ${{ steps.compute_tag.outputs.tag }}
prerelease: ${{ steps.compute_tag.outputs.prerelease }}
tag: ${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}
prerelease: true
bodyFile: CHANGELOG.md
publish_appcast:
if: ${{ inputs.build_mac_notarized && inputs.create_release && !cancelled() && !failure() }}
needs: [determine_build_number, mac_notarized, release]
name: Publish Sparkle appcast
runs-on: macos-26
permissions:
contents: write
env:
BUILD_NUMBER: ${{ needs.determine_build_number.outputs.build_number }}
VERSION_NUMBER: ${{ needs.determine_build_number.outputs.version_number }}
RELEASE_CHANNEL: ${{ inputs.release_channel }}
RELEASE_TAG: ${{ needs.release.outputs.tag }}
SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
REPO: ${{ github.repository }}
steps:
- name: Guard — secret configured
run: |
if [ -z "$SPARKLE_ED_PRIVATE_KEY" ]; then
echo "::error::SPARKLE_ED_PRIVATE_KEY secret is not set. Configure it with the base64-encoded private key exported via 'generate_keys -x'."
exit 1
fi
- uses: actions/checkout@v4
with:
token: ${{ secrets.REPO_TOKEN }}
- name: Download notarized mac artifact
uses: actions/download-artifact@v4
with:
name: mac-notarized-build
path: mac-artifacts
- name: Locate sign_update binary
id: find_sign_update
run: |
# Sparkle's `sign_update` ships as a package artifact. We need SPM to
# resolve the Sparkle package so the binary is present on disk.
xcodebuild -resolvePackageDependencies -project Yattee.xcodeproj -scheme Yattee >/dev/null
SIGN=$(find "$HOME/Library/Developer/Xcode/DerivedData" -name sign_update -type f 2>/dev/null | head -1)
if [ -z "$SIGN" ]; then
SIGN=$(find ~ -name sign_update -type f 2>/dev/null | head -1)
fi
if [ -z "$SIGN" ]; then
echo "::error::Could not locate sign_update binary"
exit 1
fi
echo "sign_update=$SIGN" >> "$GITHUB_OUTPUT"
- name: Checkout gh-pages (create if missing)
run: |
# Fetch into a local branch: actions/checkout configures a narrow
# refspec, so a plain `git fetch origin gh-pages` never creates
# origin/gh-pages, and a detached worktree has no local branch for
# `git push origin gh-pages` to resolve.
if git fetch origin +refs/heads/gh-pages:refs/heads/gh-pages; then
git worktree add gh-pages gh-pages
else
# First run — create orphan gh-pages with only appcast scaffolding.
git worktree add --detach gh-pages HEAD
cd gh-pages
git checkout --orphan gh-pages
git rm -rf . >/dev/null 2>&1 || true
cp ../scripts/sparkle/appcast_template.xml appcast.xml
cd ..
fi
- name: Write private key to a temp file
id: ed_key
run: |
KEY_FILE=$(mktemp)
printf '%s' "$SPARKLE_ED_PRIVATE_KEY" > "$KEY_FILE"
echo "path=$KEY_FILE" >> "$GITHUB_OUTPUT"
- name: Sign update and update appcast.xml
run: |
ZIP=$(find mac-artifacts -name '*.zip' | head -1)
if [ -z "$ZIP" ]; then
echo "::error::No .zip found in mac-artifacts"
exit 1
fi
./scripts/sparkle/update_appcast.rb \
--zip "$ZIP" \
--version "$VERSION_NUMBER" \
--build "$BUILD_NUMBER" \
--channel "$RELEASE_CHANNEL" \
--tag "$RELEASE_TAG" \
--sign-update-bin "${{ steps.find_sign_update.outputs.sign_update }}" \
--ed-key-file "${{ steps.ed_key.outputs.path }}" \
--appcast gh-pages/appcast.xml \
--repo "$REPO"
- name: Scrub private key
if: always()
run: rm -f "${{ steps.ed_key.outputs.path }}"
- name: Commit & push appcast.xml
run: |
cd gh-pages
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add appcast.xml
if git diff --cached --quiet; then
echo "No appcast changes to publish"
else
git commit -m "Publish Sparkle appcast: ${VERSION_NUMBER} (${BUILD_NUMBER}) [${RELEASE_CHANNEL}]"
git push origin gh-pages
fi
update_altstore:
# Only when an iOS IPA was actually built and released — a mac/tvOS-only
# release has no IPA and would corrupt the AltStore source.
if: ${{ inputs.build_ios && success() }}
needs: [release]
uses: ./.github/workflows/update-altstore.yml
secrets: inherit
with:
tag: ${{ needs.release.outputs.tag }}

View File

@@ -1,16 +1,7 @@
name: Update AltStore source
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to publish (defaults to the latest release)'
type: string
required: false
workflow_call:
inputs:
tag:
type: string
required: false
jobs:
update_altstore:
@@ -22,32 +13,18 @@ jobs:
- uses: actions/checkout@v4
with:
ref: main
# Default GITHUB_TOKEN cannot bypass the "changes through PR only"
# branch ruleset; REPO_TOKEN can (same as the release workflow).
token: ${{ secrets.REPO_TOKEN }}
- name: Get version info from release
- name: Get version info from latest release
run: |
TAG="${{ inputs.tag }}"
if [ -z "$TAG" ]; then
TAG=$(gh release view --json tagName --jq '.tagName')
fi
# Tags are <version>-<build> (stable) or <version>-beta.<build> (beta),
# e.g. 2.0.0-263 or 2.0.0-beta.263.
echo "TAG=${TAG}" >> $GITHUB_ENV
echo "VERSION_NUMBER=${TAG%%-*}" >> $GITHUB_ENV
echo "BUILD_NUMBER=${TAG##*[-.]}" >> $GITHUB_ENV
echo "VERSION_NUMBER=${TAG%-*}" >> $GITHUB_ENV
echo "BUILD_NUMBER=${TAG##*-}" >> $GITHUB_ENV
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Get IPA size from release
run: |
IPA_NAME="Yattee-${{ env.VERSION_NUMBER }}-iOS.ipa"
SIZE=$(gh release view "${{ env.TAG }}" --json assets --jq ".assets[] | select(.name == \"$IPA_NAME\") | .size")
if [ -z "$SIZE" ]; then
echo "::error::Release ${{ env.TAG }} has no asset named $IPA_NAME — refusing to publish a broken AltStore entry"
exit 1
fi
echo "IPA_NAME=${IPA_NAME}" >> $GITHUB_ENV
echo "IPA_SIZE=${SIZE}" >> $GITHUB_ENV
SIZE=$(gh release view "${{ env.TAG }}" --json assets --jq '.assets[] | select(.name == "Yattee.ipa") | .size')
echo "IPA_SIZE=${SIZE:-0}" >> $GITHUB_ENV
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Update altstore-source.json
@@ -56,7 +33,7 @@ jobs:
jq --arg version "${{ env.VERSION_NUMBER }}" \
--arg build "${{ env.BUILD_NUMBER }}" \
--arg date "$DATE" \
--arg url "https://github.com/yattee/yattee/releases/download/${{ env.TAG }}/${{ env.IPA_NAME }}" \
--arg url "https://github.com/yattee/yattee/releases/download/${{ env.TAG }}/Yattee.ipa" \
--argjson size "${{ env.IPA_SIZE }}" \
'.apps[0].versions = [{
version: $version,

View File

@@ -13,18 +13,6 @@ This project targets the latest OS versions only - use newest APIs freely withou
**Test (single):** `xcodebuild test -scheme Yattee -destination 'platform=macOS' -only-testing:YatteeTests/TestSuiteName/testMethodName`
**Lint:** `periphery scan` (config: `.periphery.yml`)
## Build Configurations
Three configurations exist, mapped to distribution channels:
| Configuration | Sparkle (`#if SPARKLE`) | Used for |
|---|---|---|
| `Debug` | off | local development, tests |
| `Release` | off | App Store / TestFlight (`fastlane mac beta`) — must stay Sparkle-free, App Review rejects auto-update frameworks |
| `Release-DeveloperID` | **on** | Developer ID notarized build (`fastlane mac build_and_notarize`), distributed via GitHub Releases + Homebrew cask, receives Sparkle updates |
All Sparkle-dependent code must be wrapped in `#if SPARKLE ... #endif` so the `Release` variant links zero Sparkle symbols. When adding new Sparkle features, test both configs build clean on macOS.
## Code Style
**Language:** Swift 5.0+ with strict concurrency (Swift 6 mode enabled)

View File

@@ -1,29 +1,37 @@
## What's Changed
### New Features
* Persist media browser view options per source
* Add Enable All / Disable All menu to channel notifications settings
* Add context menu and swipe actions to related videos in Video Info View
* Persist author cache to disk for instant channel info across restarts
* Add embedded audio/subtitle track selection for multi-track files
* Add loading external subtitle files for media-source playback
* Add view options to playlists list view
* Add search to playlists list view
* Show and label WebDAV/SMB video streams in the quality selector
### Improvements
* Change default player layout settings
* Show video thumbnail in mini player during PiP
* Update media browser view options sheet layout
* Move close video button from toolbar into now playing card in Remote Control
### Bug Fixes
* Fix deleted playlists resurrecting from iCloud after app restart
* Fix feed channel filter avatars showing placeholders instead of images
* Fix Invidious login failing for passwords with special characters
* Fix subscriber count layout shift in Video Info View channel row
* Fix Feed tab flashing Content Unavailable View on initial load
* Fix blurred background gradient not using DeArrow thumbnail
* Fix playlist rows in Channel View not tappable in empty space
* Fix lock screen always showing 10s seek regardless of system controls setting
* Fix player dismiss gesture stuck after panel dismiss with comments expanded
* Fix incomplete playlist loading by paginating through all pages
* Fix pull-to-refresh scroll offset not resetting in Instance Browse View
* Fix URL scheme UI tests for YouTube deep links and content loading
* Fix UI tests for onboarding flow and AddRemoteServer redesign
* Fix panscan zoom pushing controls off screen for portrait videos
* Fix timed links resuming at watch position instead of URL timestamp
* Fix autoplay countdown showing in repeat one queue mode
* Fix double-tap fullscreen gesture rotating on portrait videos
* Fix sending extracted videos (Twitch streams) to other devices
* Fix missing thumbnails for videos saved to library
* Fix #955: make subtitle appearance settings adjustable on tvOS
* Fix #960: scope subscription counts, import/export to active account
* Fix #958: switch MPVKit to yattee fork with AV1 VideoToolbox session recovery
* Fix startup crash in sideloaded builds without iCloud entitlements
* Allow screen sleep during audio-only playback
* Apply thumbnail fallback everywhere a single URL was rendered
* Stop tracking resume progress for live streams
* Label local-file video quality from mpv track info instead of Unknown
* Keep macOS player fullscreen when queue advances to different-aspect video
* Guard PiP bridge geometry writes against non-finite rects
### Development
* Add Fastlane config and update release workflow for v2
* Add DEV badge on iCloud settings for debug builds
* Add git-cliff based changelog generator
* Add AltStore source and separate update workflow from release pipeline
* Add URL scheme UI tests for deep link navigation
* Refactor views

162
CLAUDE.md Normal file
View File

@@ -0,0 +1,162 @@
# 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

1
CNAME
View File

@@ -1 +0,0 @@
dl.yattee.stream

View File

@@ -3,14 +3,14 @@ GEM
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.8.9)
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.1231.0)
aws-sdk-core (3.244.0)
aws-partitions (1.1213.0)
aws-sdk-core (3.242.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -18,11 +18,11 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.123.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-kms (1.121.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.217.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-s3 (1.213.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
@@ -30,7 +30,7 @@ GEM
babosa (1.0.4)
base64 (0.2.0)
benchmark (0.5.0)
bigdecimal (4.1.0)
bigdecimal (4.0.1)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
@@ -70,11 +70,11 @@ GEM
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.4)
faraday-retry (1.0.3)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.1)
fastlane (2.232.2)
fastimage (2.4.0)
fastlane (2.232.1)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
@@ -124,9 +124,10 @@ GEM
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.1.0)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.98.0)
google-apis-androidpublisher_v3 (0.96.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1)
@@ -140,15 +141,15 @@ GEM
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.17.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.61.0)
google-apis-storage_v1 (0.60.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.1.1)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.6.0)
google-cloud-storage (1.59.0)
google-cloud-errors (1.5.0)
google-cloud-storage (1.58.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2)
@@ -170,7 +171,7 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.19.3)
json (2.18.1)
jwt (2.10.2)
base64
language_server-protocol (3.17.0.5)
@@ -188,12 +189,12 @@ GEM
os (1.1.4)
ostruct (0.6.3)
parallel (1.27.0)
parser (3.3.11.1)
parser (3.3.10.1)
ast (~> 2.4.1)
racc
plist (3.7.2)
prism (1.9.0)
public_suffix (7.0.5)
public_suffix (7.0.2)
racc (1.8.1)
rainbow (3.1.1)
rake (13.3.1)
@@ -202,7 +203,7 @@ GEM
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.4.1)
retriable (3.1.2)
rexml (3.4.4)
rouge (3.28.0)
rspec (3.13.2)
@@ -214,13 +215,13 @@ GEM
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.8)
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.7)
rubocop (1.86.0)
rubocop (1.84.2)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -231,7 +232,7 @@ GEM
rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.49.1)
rubocop-ast (1.49.0)
parser (>= 3.3.7.2)
prism (~> 1.7)
rubocop-rspec (3.9.0)
@@ -249,6 +250,7 @@ GEM
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)

111
README.md
View File

@@ -1,26 +1,113 @@
<div align="center">
<img src="assets/yattee-logo.png" width="128" height="128" alt="Yattee logo" />
<img src="Assets/yattee-logo.png" width="150" height="150" alt="Yattee logo">
<h1>Yattee</h1>
<p>Privacy oriented video player for iOS, tvOS and macOS<br /></p>
<p>Privacy-focused video player for iPhone, iPad, Mac, and Apple TV</p>
[![AGPL v3](https://shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0.en.html)
[![GitHub issues](https://img.shields.io/github/issues/yattee/yattee)](https://github.com/yattee/yattee/issues)
[![GitHub pull requests](https://img.shields.io/github/issues-pr/yattee/yattee)](https://github.com/yattee/yattee/pulls)
[![Discord](https://invidget.switchblade.xyz/pSnNKhZHEG)](https://yattee.stream/discord)
</div>
> **Yattee 2 is in the works!** A new version of the app is being built with a refreshed experience.
> It pairs with the new [Yattee Server](https://github.com/yattee/yattee-server) — a self-hosted backend powered by yt-dlp that supports 1000+ sites.
> Join the [TestFlight beta](https://yattee.stream/beta2) to try early builds, and check the new documentation site at [docs.yattee.stream](https://docs.yattee.stream) for guides, roadmap and changelog.
## Install
<a href="https://yattee.stream/beta2">
<img src="https://developer.apple.com/assets/elements/icons/testflight/testflight-64x64_2x.png" height="40" alt="TestFlight">
</a>
[Join the TestFlight beta](https://yattee.stream/beta2)
<!-- App Store link coming soon -->
<!-- TODO: new screenshot assets -->
## Features
* Native user interface built with [SwiftUI](https://developer.apple.com/swiftui/) with customization settings
* Player queue and history
* Player component with custom controls, gestures and support for 4K playback
* Fullscreen, Picture in Picture and background audio playback
* [SponsorBlock](https://sponsor.ajay.app/), configurable categories to skip
**Playback**
- 4K video with custom MPV-based player (H.264, H.265, VP9, AV1)
- 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)
- Seek preview with storyboards
**Content Sources**
- YouTube via Invidious, Piped, or self-hosted Yattee Server
- PeerTube instances (federated video)
- Local files, SMB network shares, WebDAV servers
**Integrations**
- [SponsorBlock](https://sponsor.ajay.app/) (configurable skip categories)
- [DeArrow](https://dearrow.ajay.app/) (crowdsourced titles and thumbnails)
- [Return YouTube Dislike](https://returnyoutubedislike.com/)
**Privacy**
- No tracking, no ads, no account required
- All traffic goes through your chosen instances
**Library**
- Subscriptions with per-channel notifications
- Bookmarks with tags and notes, playlists, watch history
- Unified search across all configured sources
- Import/export subscriptions (JSON, CSV, OPML)
**Downloads & Sync**
- Offline video and audio downloads with quality selection
- iCloud sync for bookmarks, subscriptions, history, and settings across devices
- Handoff continuity between iPhone, iPad, Mac, and Apple TV
**Apple Ecosystem**
- iOS 18+ / macOS 15+ / tvOS 18+
- Native SwiftUI on every platform
- Customizable home layout, accent colors, player controls, and app icon
- Clipboard URL detection and deep linking (`yattee://`)
- Remote control between devices on your network
## Yattee Server
A self-hosted backend powered by [yt-dlp](https://github.com/yt-dlp/yt-dlp) that gives Yattee superpowers.
- **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
Check out the [yattee-server](https://github.com/yattee/yattee-server) repository to get started.
## Documentation
- [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)
## Contributing
Browse the [issues](https://github.com/yattee/yattee/issues) list or open a new one to discuss your idea. Every contribution is welcome.
See [AGENTS.md](AGENTS.md) for developer setup and project architecture.
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.
## Translations
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 and its components is shared on [AGPL v3](https://www.gnu.org/licenses/agpl-3.0.en.html) license.
Yattee is shared under the [AGPL v3](https://www.gnu.org/licenses/agpl-3.0.en.html) license.

View File

@@ -7,14 +7,12 @@
objects = {
/* Begin PBXBuildFile section */
370E71982F9A1A41000E04B2 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; platformFilters = (macos, ); productRef = 370E71972F9A1A41000E04B2 /* Sparkle */; };
37767AB32F05766100D248FC /* Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = 37767AB22F05766100D248FC /* Nuke */; };
37767AB52F05766100D248FC /* NukeUI in Frameworks */ = {isa = PBXBuildFile; productRef = 37767AB42F05766100D248FC /* NukeUI */; };
378CF2FE2EF21767002C1CD7 /* MPVKit-GPL in Frameworks */ = {isa = PBXBuildFile; productRef = 378CF2FD2EF21767002C1CD7 /* MPVKit-GPL */; };
378CF3012EF21783002C1CD7 /* MPVKit-GPL in Frameworks */ = {isa = PBXBuildFile; productRef = 378CF3002EF21783002C1CD7 /* MPVKit-GPL */; };
37BA19A62EE4DFEE001D7B0F /* Yattee2.icon in Resources */ = {isa = PBXBuildFile; fileRef = 37BA19A52EE4DFEE001D7B0F /* Yattee2.icon */; };
37BA19B52EE4EB7F001D7B0F /* YatteeShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 37BA19AB2EE4EB7F001D7B0F /* YatteeShareExtension.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
37C0AAAA00000000AAAA000C /* YatteeTopShelf.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 37C0AAAA00000000AAAA0002 /* YatteeTopShelf.appex */; platformFilters = (tvos, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -25,13 +23,6 @@
remoteGlobalIDString = 37BA19AA2EE4EB7F001D7B0F;
remoteInfo = YatteeShareExtension;
};
37C0AAAA00000000AAAA0008 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 372D1A1F2EDB163800F58F7A /* Project object */;
proxyType = 1;
remoteGlobalIDString = 37C0AAAA00000000AAAA0001;
remoteInfo = YatteeTopShelf;
};
37D0B2982EDB23BD00B9C4ED /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 372D1A1F2EDB163800F58F7A /* Project object */;
@@ -49,7 +40,6 @@
dstSubfolderSpec = 13;
files = (
37BA19B52EE4EB7F001D7B0F /* YatteeShareExtension.appex in Embed Foundation Extensions */,
37C0AAAA00000000AAAA000C /* YatteeTopShelf.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
@@ -60,7 +50,6 @@
372D1A272EDB163800F58F7A /* Yattee.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Yattee.app; sourceTree = BUILT_PRODUCTS_DIR; };
37BA19A52EE4DFEE001D7B0F /* Yattee2.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = Yattee2.icon; sourceTree = "<group>"; };
37BA19AB2EE4EB7F001D7B0F /* YatteeShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = YatteeShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
37C0AAAA00000000AAAA0002 /* YatteeTopShelf.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = YatteeTopShelf.appex; sourceTree = BUILT_PRODUCTS_DIR; };
37D0B2942EDB23BD00B9C4ED /* YatteeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = YatteeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
@@ -68,7 +57,6 @@
37A11F692EDC5DD700213864 /* Exceptions for "Yattee" folder in "Yattee" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
"Info-tvOS.plist",
Info.plist,
);
target = 372D1A262EDB163800F58F7A /* Yattee */;
@@ -80,13 +68,6 @@
);
target = 37BA19AA2EE4EB7F001D7B0F /* YatteeShareExtension */;
};
37C0AAAA00000000AAAA000D /* Exceptions for "YatteeTopShelf" folder in "YatteeTopShelf" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 37C0AAAA00000000AAAA0001 /* YatteeTopShelf */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -106,14 +87,6 @@
path = YatteeShareExtension;
sourceTree = "<group>";
};
37C0AAAA00000000AAAA0003 /* YatteeTopShelf */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
37C0AAAA00000000AAAA000D /* Exceptions for "YatteeTopShelf" folder in "YatteeTopShelf" target */,
);
path = YatteeTopShelf;
sourceTree = "<group>";
};
37D0B2952EDB23BD00B9C4ED /* YatteeTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = YatteeTests;
@@ -126,7 +99,6 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
370E71982F9A1A41000E04B2 /* Sparkle in Frameworks */,
37767AB32F05766100D248FC /* Nuke in Frameworks */,
378CF2FE2EF21767002C1CD7 /* MPVKit-GPL in Frameworks */,
378CF3012EF21783002C1CD7 /* MPVKit-GPL in Frameworks */,
@@ -141,13 +113,6 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
37C0AAAA00000000AAAA0005 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
37D0B2912EDB23BD00B9C4ED /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -164,7 +129,6 @@
372D1A292EDB163800F58F7A /* Yattee */,
37D0B2952EDB23BD00B9C4ED /* YatteeTests */,
37BA19AC2EE4EB7F001D7B0F /* YatteeShareExtension */,
37C0AAAA00000000AAAA0003 /* YatteeTopShelf */,
372D1A282EDB163800F58F7A /* Products */,
37BA19A52EE4DFEE001D7B0F /* Yattee2.icon */,
);
@@ -176,7 +140,6 @@
372D1A272EDB163800F58F7A /* Yattee.app */,
37D0B2942EDB23BD00B9C4ED /* YatteeTests.xctest */,
37BA19AB2EE4EB7F001D7B0F /* YatteeShareExtension.appex */,
37C0AAAA00000000AAAA0002 /* YatteeTopShelf.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -192,13 +155,11 @@
372D1A242EDB163800F58F7A /* Frameworks */,
372D1A252EDB163800F58F7A /* Resources */,
37BA19BA2EE4EB7F001D7B0F /* Embed Foundation Extensions */,
37C0FEE100000000FEE100A1 /* Strip Sparkle (non-DeveloperID) */,
);
buildRules = (
);
dependencies = (
37BA19B42EE4EB7F001D7B0F /* PBXTargetDependency */,
37C0AAAA00000000AAAA0007 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
372D1A292EDB163800F58F7A /* Yattee */,
@@ -209,7 +170,6 @@
378CF3002EF21783002C1CD7 /* MPVKit-GPL */,
37767AB22F05766100D248FC /* Nuke */,
37767AB42F05766100D248FC /* NukeUI */,
370E71972F9A1A41000E04B2 /* Sparkle */,
);
productName = Yattee;
productReference = 372D1A272EDB163800F58F7A /* Yattee.app */;
@@ -237,28 +197,6 @@
productReference = 37BA19AB2EE4EB7F001D7B0F /* YatteeShareExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
37C0AAAA00000000AAAA0001 /* YatteeTopShelf */ = {
isa = PBXNativeTarget;
buildConfigurationList = 37C0AAAA00000000AAAA0009 /* Build configuration list for PBXNativeTarget "YatteeTopShelf" */;
buildPhases = (
37C0AAAA00000000AAAA0004 /* Sources */,
37C0AAAA00000000AAAA0005 /* Frameworks */,
37C0AAAA00000000AAAA0006 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
37C0AAAA00000000AAAA0003 /* YatteeTopShelf */,
);
name = YatteeTopShelf;
packageProductDependencies = (
);
productName = YatteeTopShelf;
productReference = 37C0AAAA00000000AAAA0002 /* YatteeTopShelf.appex */;
productType = "com.apple.product-type.tv-app-extension";
};
37D0B2932EDB23BD00B9C4ED /* YatteeTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 37D0B29A2EDB23BD00B9C4ED /* Build configuration list for PBXNativeTarget "YatteeTests" */;
@@ -298,9 +236,6 @@
37BA19AA2EE4EB7F001D7B0F = {
CreatedOnToolsVersion = 26.2;
};
37C0AAAA00000000AAAA0001 = {
CreatedOnToolsVersion = 26.2;
};
37D0B2932EDB23BD00B9C4ED = {
CreatedOnToolsVersion = 26.1.1;
TestTargetID = 372D1A262EDB163800F58F7A;
@@ -319,7 +254,6 @@
packageReferences = (
378CF2FF2EF21783002C1CD7 /* XCRemoteSwiftPackageReference "MPVKit" */,
37767AB12F05766100D248FC /* XCRemoteSwiftPackageReference "Nuke" */,
370E71962F9A1A41000E04B2 /* XCRemoteSwiftPackageReference "Sparkle" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 372D1A282EDB163800F58F7A /* Products */;
@@ -329,7 +263,6 @@
372D1A262EDB163800F58F7A /* Yattee */,
37D0B2932EDB23BD00B9C4ED /* YatteeTests */,
37BA19AA2EE4EB7F001D7B0F /* YatteeShareExtension */,
37C0AAAA00000000AAAA0001 /* YatteeTopShelf */,
);
};
/* End PBXProject section */
@@ -350,13 +283,6 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
37C0AAAA00000000AAAA0006 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
37D0B2922EDB23BD00B9C4ED /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -366,28 +292,6 @@
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
37C0FEE100000000FEE100A1 /* Strip Sparkle (non-DeveloperID) */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Strip Sparkle (non-DeveloperID)";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "if [ \"${CONFIGURATION}\" = \"Release-DeveloperID\" ] || [ \"${CONFIGURATION}\" = \"Debug\" ]; then\n echo \"Skipping Sparkle strip (config=${CONFIGURATION})\"\n exit 0\nfi\nFW=\"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Sparkle.framework\"\nif [ -d \"$FW\" ]; then\n echo \"Stripping Sparkle.framework (config=${CONFIGURATION})\"\n chflags -R nouchg \"$FW\" 2>/dev/null || true\n chmod -R u+w \"$FW\" 2>/dev/null || true\n TMP=\"${FW}.stripping.$$\"\n if mv \"$FW\" \"$TMP\" 2>/dev/null; then\n TARGET=\"$TMP\"\n else\n TARGET=\"$FW\"\n fi\n for i in 1 2 3 4 5; do\n rm -rf \"$TARGET\" && break\n sleep 0.2\n done\n if [ -e \"$TARGET\" ]; then\n echo \"warning: failed to fully remove $TARGET\" >&2\n exit 1\n fi\nfi\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
372D1A232EDB163800F58F7A /* Sources */ = {
isa = PBXSourcesBuildPhase;
@@ -403,13 +307,6 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
37C0AAAA00000000AAAA0004 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
37D0B2902EDB23BD00B9C4ED /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -426,14 +323,6 @@
target = 37BA19AA2EE4EB7F001D7B0F /* YatteeShareExtension */;
targetProxy = 37BA19B32EE4EB7F001D7B0F /* PBXContainerItemProxy */;
};
37C0AAAA00000000AAAA0007 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
platformFilters = (
tvos,
);
target = 37C0AAAA00000000AAAA0001 /* YatteeTopShelf */;
targetProxy = 37C0AAAA00000000AAAA0008 /* PBXContainerItemProxy */;
};
37D0B2992EDB23BD00B9C4ED /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 372D1A262EDB163800F58F7A /* Yattee */;
@@ -570,7 +459,7 @@
AUTOMATION_APPLE_EVENTS = NO;
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 269;
CURRENT_PROJECT_VERSION = 252;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
ENABLE_APP_SANDBOX = YES;
@@ -587,8 +476,7 @@
ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO;
ENABLE_RESOURCE_ACCESS_PRINTING = NO;
ENABLE_RESOURCE_ACCESS_USB = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
ENABLE_USER_SELECTED_FILES = readwrite;
ENABLE_USER_SELECTED_FILES = readonly;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
@@ -601,11 +489,8 @@
"$(BUILT_PRODUCTS_DIR)/Libsmbclient.framework/Headers/samba-4.0",
);
INFOPLIST_FILE = Yattee/Info.plist;
"INFOPLIST_FILE[sdk=appletvos*]" = "Yattee/Info-tvOS.plist";
"INFOPLIST_FILE[sdk=appletvsimulator*]" = "Yattee/Info-tvOS.plist";
INFOPLIST_KEY_CFBundleDisplayName = Yattee;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video";
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Arkadiusz Fal";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Yattee uses local network to discover and control playback on other Yattee devices.";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
@@ -654,9 +539,8 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
AUTOMATION_APPLE_EVENTS = NO;
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Yattee/Yattee-macOS.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 269;
CURRENT_PROJECT_VERSION = 252;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
ENABLE_APP_SANDBOX = YES;
@@ -673,8 +557,7 @@
ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO;
ENABLE_RESOURCE_ACCESS_PRINTING = NO;
ENABLE_RESOURCE_ACCESS_USB = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
ENABLE_USER_SELECTED_FILES = readwrite;
ENABLE_USER_SELECTED_FILES = readonly;
GCC_PREPROCESSOR_DEFINITIONS = (
"GLES_SILENCE_DEPRECATION=1",
"GL_SILENCE_DEPRECATION=1",
@@ -685,11 +568,8 @@
"$(BUILT_PRODUCTS_DIR)/Libsmbclient.framework/Headers/samba-4.0",
);
INFOPLIST_FILE = Yattee/Info.plist;
"INFOPLIST_FILE[sdk=appletvos*]" = "Yattee/Info-tvOS.plist";
"INFOPLIST_FILE[sdk=appletvsimulator*]" = "Yattee/Info-tvOS.plist";
INFOPLIST_KEY_CFBundleDisplayName = Yattee;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video";
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Arkadiusz Fal";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Yattee uses local network to discover and control playback on other Yattee devices.";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
@@ -706,10 +586,6 @@
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 2.0.0;
"OTHER_LDFLAGS[sdk=macosx*]" = (
"$(inherited)",
"-Wl,-dead_strip_dylibs",
);
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -735,249 +611,12 @@
};
name = Release;
};
376D70452F996E0700255B09 /* Release-DeveloperID */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = "Release-DeveloperID";
};
376D70462F996E0700255B09 /* Release-DeveloperID */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = Yattee2;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
AUTOMATION_APPLE_EVENTS = NO;
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Yattee/Yattee-macOS-DeveloperID.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 269;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_INCOMING_NETWORK_CONNECTIONS = YES;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_PREVIEWS = YES;
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = YES;
ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO;
ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
ENABLE_RESOURCE_ACCESS_CAMERA = NO;
ENABLE_RESOURCE_ACCESS_CONTACTS = NO;
ENABLE_RESOURCE_ACCESS_LOCATION = NO;
ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO;
ENABLE_RESOURCE_ACCESS_PRINTING = NO;
ENABLE_RESOURCE_ACCESS_USB = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
ENABLE_USER_SELECTED_FILES = readwrite;
GCC_PREPROCESSOR_DEFINITIONS = (
"GLES_SILENCE_DEPRECATION=1",
"GL_SILENCE_DEPRECATION=1",
);
GENERATE_INFOPLIST_FILE = YES;
HEADER_SEARCH_PATHS = (
"$(BUILD_DIR)/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/Libsmbclient.framework/Headers/samba-4.0",
"$(BUILT_PRODUCTS_DIR)/Libsmbclient.framework/Headers/samba-4.0",
);
INFOPLIST_FILE = Yattee/Info.plist;
"INFOPLIST_FILE[sdk=appletvos*]" = "Yattee/Info-tvOS.plist";
"INFOPLIST_FILE[sdk=appletvsimulator*]" = "Yattee/Info-tvOS.plist";
INFOPLIST_KEY_CFBundleDisplayName = Yattee;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video";
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Arkadiusz Fal";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Yattee uses local network to discover and control playback on other Yattee devices.";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO;
RUNTIME_EXCEPTION_ALLOW_JIT = NO;
RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = YES;
RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO;
RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO;
RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "SPARKLE $(inherited)";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "$(SRCROOT)/Yattee/Yattee-Bridging-Header.h";
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,3";
TVOS_DEPLOYMENT_TARGET = 18.0;
XROS_DEPLOYMENT_TARGET = 26.1;
};
name = "Release-DeveloperID";
};
376D70472F996E0700255B09 /* Release-DeveloperID */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 269;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app.YatteeTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,3";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Yattee.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Yattee";
TVOS_DEPLOYMENT_TARGET = 18.0;
XROS_DEPLOYMENT_TARGET = 26.1;
};
name = "Release-DeveloperID";
};
376D70482F996E0700255B09 /* Release-DeveloperID */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 269;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Yattee Share Extension";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 18;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = "Release-DeveloperID";
};
376D70492F996E0700255B09 /* Release-DeveloperID */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 269;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeTopShelf/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Yattee Top Shelf";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_ENTRY_POINT = _NSExtensionMain;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app.TopShelf;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = appletvos;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 3;
TVOS_DEPLOYMENT_TARGET = 18.0;
VALIDATE_PRODUCT = YES;
};
name = "Release-DeveloperID";
};
37BA19B72EE4EB7F001D7B0F /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 269;
CURRENT_PROJECT_VERSION = 252;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeShareExtension/Info.plist;
@@ -1008,7 +647,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 269;
CURRENT_PROJECT_VERSION = 252;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeShareExtension/Info.plist;
@@ -1035,81 +674,12 @@
};
name = Release;
};
37C0AAAA00000000AAAA000A /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 269;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeTopShelf/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Yattee Top Shelf";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_ENTRY_POINT = _NSExtensionMain;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app.TopShelf;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = appletvos;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 3;
TVOS_DEPLOYMENT_TARGET = 18.0;
};
name = Debug;
};
37C0AAAA00000000AAAA000B /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 269;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeTopShelf/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Yattee Top Shelf";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_ENTRY_POINT = _NSExtensionMain;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app.TopShelf;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = appletvos;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 3;
TVOS_DEPLOYMENT_TARGET = 18.0;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
37D0B29B2EDB23BD00B9C4ED /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 269;
CURRENT_PROJECT_VERSION = 252;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
@@ -1138,7 +708,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 269;
CURRENT_PROJECT_VERSION = 252;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
@@ -1170,7 +740,6 @@
buildConfigurations = (
372D1A302EDB163900F58F7A /* Debug */,
372D1A312EDB163900F58F7A /* Release */,
376D70452F996E0700255B09 /* Release-DeveloperID */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
@@ -1180,7 +749,6 @@
buildConfigurations = (
372D1A332EDB163900F58F7A /* Debug */,
372D1A342EDB163900F58F7A /* Release */,
376D70462F996E0700255B09 /* Release-DeveloperID */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
@@ -1190,17 +758,6 @@
buildConfigurations = (
37BA19B72EE4EB7F001D7B0F /* Debug */,
37BA19B82EE4EB7F001D7B0F /* Release */,
376D70482F996E0700255B09 /* Release-DeveloperID */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
37C0AAAA00000000AAAA0009 /* Build configuration list for PBXNativeTarget "YatteeTopShelf" */ = {
isa = XCConfigurationList;
buildConfigurations = (
37C0AAAA00000000AAAA000A /* Debug */,
37C0AAAA00000000AAAA000B /* Release */,
376D70492F996E0700255B09 /* Release-DeveloperID */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
@@ -1210,7 +767,6 @@
buildConfigurations = (
37D0B29B2EDB23BD00B9C4ED /* Debug */,
37D0B29C2EDB23BD00B9C4ED /* Release */,
376D70472F996E0700255B09 /* Release-DeveloperID */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
@@ -1218,14 +774,6 @@
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
370E71962F9A1A41000E04B2 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/sparkle-project/Sparkle";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.9.1;
};
};
37767AB12F05766100D248FC /* XCRemoteSwiftPackageReference "Nuke" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/kean/Nuke";
@@ -1236,20 +784,15 @@
};
378CF2FF2EF21783002C1CD7 /* XCRemoteSwiftPackageReference "MPVKit" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/yattee/MPVKit.git";
repositoryURL = "https://github.com/mpvkit/MPVKit.git";
requirement = {
kind = exactVersion;
version = 1.0.1;
kind = upToNextMajorVersion;
minimumVersion = 0.41.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
370E71972F9A1A41000E04B2 /* Sparkle */ = {
isa = XCSwiftPackageProductDependency;
package = 370E71962F9A1A41000E04B2 /* XCRemoteSwiftPackageReference "Sparkle" */;
productName = Sparkle;
};
37767AB22F05766100D248FC /* Nuke */ = {
isa = XCSwiftPackageProductDependency;
package = 37767AB12F05766100D248FC /* XCRemoteSwiftPackageReference "Nuke" */;

View File

@@ -1,13 +1,13 @@
{
"originHash" : "3936a4172c132dff2ced455a1771e12f4759645440fa59d5b93f5a78b68d3ee4",
"originHash" : "50421f80aba6d558399198148743d5d479edb3f6cc10024acd55828f8cf63959",
"pins" : [
{
"identity" : "mpvkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/yattee/MPVKit.git",
"location" : "https://github.com/mpvkit/MPVKit.git",
"state" : {
"revision" : "671d3484b00f438407564573a4e188631d81bc90",
"version" : "1.0.1"
"revision" : "613c0ccc3acf70e136aaff880a9b5fe8fdfaf5b8",
"version" : "0.41.0"
}
},
{
@@ -15,17 +15,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/kean/Nuke",
"state" : {
"revision" : "83e19143355b02e9261edb2323b3e1e93287ebb9",
"version" : "12.9.0"
}
},
{
"identity" : "sparkle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle",
"state" : {
"revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7",
"version" : "2.9.4"
"revision" : "0ead44350d2737db384908569c012fe67c421e4d",
"version" : "12.8.0"
}
}
],

View File

@@ -1,9 +0,0 @@
{
"id" : "8948cba2-509e-4ad8-bcb9-592de5bff7d1",
"targets" : [
{
"id" : "9FC1C0A4-CE29-437C-BB50-C93FE779B691",
"name" : "Yattee"
}
]
}

View File

@@ -21,6 +21,20 @@
ReferencedContainer = "container:Yattee.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "37BA19AA2EE4EB7F001D7B0F"
BuildableName = "YatteeShareExtension.appex"
BlueprintName = "YatteeShareExtension"
ReferencedContainer = "container:Yattee.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction

View File

@@ -78,11 +78,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
LoggingService.shared.logCloudKit("Requesting remote notification registration...")
NSApplication.shared.registerForRemoteNotifications()
if let rawValue = UserDefaults.standard.string(forKey: "appIcon"),
let icon = AppIcon(rawValue: rawValue) {
SettingsManager.applyMacAppIcon(icon)
}
}
func application(_ application: NSApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 95 KiB

View File

@@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "tvOS-AppStore-1280x768.png",
"idiom" : "tv"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -1,17 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"layers" : [
{
"filename" : "Front.imagestacklayer"
},
{
"filename" : "Middle.imagestacklayer"
},
{
"filename" : "Back.imagestacklayer"
}
]
}

View File

@@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "tvOS-AppStore-1280x768.png",
"idiom" : "tv"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -1,7 +0,0 @@
{
"images" : [],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -1,18 +0,0 @@
{
"images" : [
{
"filename" : "tvOS-Small@1x.png",
"idiom" : "tv",
"scale" : "1x"
},
{
"filename" : "tvOS-Small@2x.png",
"idiom" : "tv",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -1,17 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"layers" : [
{
"filename" : "Front.imagestacklayer"
},
{
"filename" : "Middle.imagestacklayer"
},
{
"filename" : "Back.imagestacklayer"
}
]
}

View File

@@ -1,18 +0,0 @@
{
"images" : [
{
"filename" : "tvOS-Small@1x.png",
"idiom" : "tv",
"scale" : "1x"
},
{
"filename" : "tvOS-Small@2x.png",
"idiom" : "tv",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -1,7 +0,0 @@
{
"images" : [],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -1,32 +0,0 @@
{
"assets" : [
{
"filename" : "App Icon - App Store.imagestack",
"idiom" : "tv",
"role" : "primary-app-icon",
"size" : "1280x768"
},
{
"filename" : "App Icon.imagestack",
"idiom" : "tv",
"role" : "primary-app-icon",
"size" : "400x240"
},
{
"filename" : "Top Shelf Image Wide.imageset",
"idiom" : "tv",
"role" : "top-shelf-image-wide",
"size" : "2320x720"
},
{
"filename" : "Top Shelf Image.imageset",
"idiom" : "tv",
"role" : "top-shelf-image",
"size" : "1920x720"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -1,17 +0,0 @@
{
"images" : [
{
"filename" : "TopShelf-Wide.png",
"idiom" : "tv",
"scale" : "1x"
},
{
"idiom" : "tv",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -1,17 +0,0 @@
{
"images" : [
{
"filename" : "TopShelf.png",
"idiom" : "tv",
"scale" : "1x"
},
{
"idiom" : "tv",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -57,34 +57,11 @@ struct ContentView: View {
// Mini player overlay (macOS only)
#if os(macOS)
miniPlayerOverlay(appEnvironment: appEnvironment)
// Inline expanded player: presented as a full-window overlay
// (no native sheet a window with an attached sheet can't go
// fullscreen, so fullscreen is just the window's native toggle).
if isInlinePlayerOverlayActive(appEnvironment: appEnvironment) {
ZStack {
Color.black
ExpandedPlayerSheet()
}
.ignoresSafeArea()
.transition(.opacity)
}
#endif
}
#if os(macOS)
// Registers the hosting window so the inline overlay targets the real
// main window even when another window (e.g. Settings) has focus.
.background(MainContentWindowReader())
.animation(.easeInOut(duration: 0.2), value: isInlinePlayerOverlayActive(appEnvironment: appEnvironment))
.onChange(of: isInlinePlayerOverlayActive(appEnvironment: appEnvironment)) { _, active in
if active {
ExpandedPlayerWindowManager.shared.beginInlineOverlay()
} else {
ExpandedPlayerWindowManager.shared.endInlineOverlay()
}
}
.onChange(of: appEnvironment.navigationCoordinator.playerExpandTrigger) { _, _ in
if appEnvironment.settingsManager.macPlayerSeparateWindow {
if appEnvironment.settingsManager.macPlayerMode.usesWindow {
presentExpandedPlayerWindow(appEnvironment: appEnvironment)
}
}
@@ -93,37 +70,32 @@ struct ContentView: View {
ExpandedPlayerWindowManager.shared.hide()
}
}
.onChange(of: appEnvironment.settingsManager.macPlayerSeparateWindow) { _, separateWindow in
.onChange(of: appEnvironment.settingsManager.macPlayerMode) { oldMode, newMode in
guard appEnvironment.navigationCoordinator.isPlayerExpanded else { return }
if separateWindow {
// Inline overlay separate window: the overlay unmounts via the
// presentation condition; present the window directly.
presentExpandedPlayerWindow(appEnvironment: appEnvironment)
} else {
// Separate window inline overlay: hide the window so the overlay
// (isPlayerExpanded && !separateWindow) takes over.
if oldMode.usesWindow && newMode.usesWindow {
ExpandedPlayerWindowManager.shared.updateWindowLevel(floating: newMode.isFloating)
} else if oldMode.usesWindow && !newMode.usesWindow {
ExpandedPlayerWindowManager.shared.hide(animated: false)
} else if !oldMode.usesWindow && newMode.usesWindow {
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(300))
if appEnvironment.navigationCoordinator.isPlayerExpanded {
presentExpandedPlayerWindow(appEnvironment: appEnvironment)
}
}
}
.onChange(of: appEnvironment.settingsManager.macPlayerFloating) { _, floating in
guard appEnvironment.navigationCoordinator.isPlayerExpanded,
appEnvironment.settingsManager.macPlayerSeparateWindow else { return }
ExpandedPlayerWindowManager.shared.updateWindowLevel(floating: floating)
}
.sheet(isPresented: Binding(
get: { appEnvironment.navigationCoordinator.isMiniPlayerQueueSheetPresented },
set: { appEnvironment.navigationCoordinator.isMiniPlayerQueueSheetPresented = $0 }
get: {
appEnvironment.navigationCoordinator.isPlayerExpanded &&
!appEnvironment.settingsManager.macPlayerMode.usesWindow
},
set: { appEnvironment.navigationCoordinator.isPlayerExpanded = $0 }
)) {
QueueManagementSheet()
}
.sheet(isPresented: Binding(
get: { appEnvironment.navigationCoordinator.isMiniPlayerPlaylistSheetPresented },
set: { appEnvironment.navigationCoordinator.isMiniPlayerPlaylistSheetPresented = $0 }
)) {
if let video = appEnvironment.playerService.state.currentVideo {
PlaylistSelectorSheet(video: video)
}
ExpandedPlayerSheet()
.frame(minWidth: 640, minHeight: 480)
.presentationSizing(.fitted)
}
#elseif os(tvOS)
.fullScreenCover(isPresented: Binding(
@@ -139,14 +111,6 @@ struct ContentView: View {
private func presentExpandedPlayerWindow(appEnvironment: AppEnvironment) {
ExpandedPlayerWindowManager.shared.show(with: appEnvironment, animated: true)
}
/// Whether the inline (non-separate-window) expanded player overlay should
/// be presented. Shared by the overlay mount and the begin/end onChange so
/// the two can't drift apart.
private func isInlinePlayerOverlayActive(appEnvironment: AppEnvironment) -> Bool {
appEnvironment.navigationCoordinator.isPlayerExpanded &&
!appEnvironment.settingsManager.macPlayerSeparateWindow
}
#endif
#if os(macOS)
@@ -155,17 +119,14 @@ struct ContentView: View {
let playerState = appEnvironment.playerService.state
let hasActiveVideo = playerState.currentVideo != nil
let isExpanded = appEnvironment.navigationCoordinator.isPlayerExpanded
// The expanded player is a separate window in window mode, so keep the capsule
// visible alongside it. The inline overlay covers the window, so hide it there.
let usesWindow = appEnvironment.settingsManager.macPlayerSeparateWindow
if hasActiveVideo && (!isExpanded || usesWindow) {
if hasActiveVideo && !isExpanded {
VStack(spacing: 0) {
Spacer()
MiniPlayerView()
}
// Float the capsule above the bottom edge (macOS uses a sidebar, not a tab bar)
.padding(.bottom, 16)
// Add padding for tab bar
.padding(.bottom, 49)
// Use move-only transition (no opacity) to prevent thumbnail flash during collapse
.transition(.move(edge: .bottom))
.animation(.spring(response: 0.3), value: hasActiveVideo)

View File

@@ -47,7 +47,7 @@ final class AppEnvironment {
let handoffManager: HandoffManager
let invidiousCredentialsManager: InvidiousCredentialsManager
let pipedCredentialsManager: PipedCredentialsManager
let basicAuthCredentialsManager: BasicAuthCredentialsManager
let yatteeServerCredentialsManager: YatteeServerCredentialsManager
let homeInstanceCache: HomeInstanceCache
let invidiousAPI: InvidiousAPI
let pipedAPI: PipedAPI
@@ -56,28 +56,6 @@ final class AppEnvironment {
let legacyMigrationService: LegacyDataMigrationService
let sourcesSettings: SourcesSettings
/// Center-section settings of the active player controls preset, cached for
/// synchronous access. Menu bar commands read seek durations from here since
/// they cannot await the layout service actor.
private(set) var activeControlsCenterSettings: CenterSectionSettings = .default
@ObservationIgnored private var controlsSettingsObservers: [NSObjectProtocol] = []
// MARK: - Shared Instance
/// The single, process-wide app environment.
///
/// SwiftUI may evaluate a `@State` property's default-value autoclosure more
/// than once (it keeps only the first result but still runs the side effects
/// of the discarded instances). Constructing `AppEnvironment` more than once
/// would create multiple `DownloadManager`s and therefore multiple
/// background `URLSession`s registered under the same identifier causing
/// download-completion delegate callbacks to be delivered to an instance
/// whose `activeDownloads` is empty (the finished file is then dropped).
/// Referencing this `static let` from the App's `@State` guarantees exactly
/// one instance for the lifetime of the process.
static let shared = AppEnvironment()
// MARK: - Initialization
init(
@@ -104,12 +82,12 @@ final class AppEnvironment {
instances.setSettingsManager(settings)
self.instancesManager = instances
// Initialize Basic Auth Credentials Manager early (needed for ContentService)
let basicAuthCreds = BasicAuthCredentialsManager()
basicAuthCreds.settingsManager = settings
self.basicAuthCredentialsManager = basicAuthCreds
// Initialize Yattee Server Credentials Manager early (needed for ContentService)
let yatteeServerCreds = YatteeServerCredentialsManager()
yatteeServerCreds.settingsManager = settings
self.yatteeServerCredentialsManager = yatteeServerCreds
let contentSvc = ContentService(httpClient: client, basicAuthCredentialsManager: basicAuthCreds)
let contentSvc = ContentService(httpClient: client, yatteeServerCredentialsManager: yatteeServerCreds)
self.contentService = contentSvc
self.instanceDetector = InstanceDetector(httpClient: client)
self.navigationCoordinator = navigationCoordinator ?? NavigationCoordinator()
@@ -236,7 +214,7 @@ final class AppEnvironment {
// Wire up SMB client to check if SMB playback is active
// This prevents crashes from concurrent libsmbclient usage
let smbClientRef = self.smbClient
Task { [weak player] in
Task {
await smbClientRef.setPlaybackActiveCallback { [weak player] in
player?.state.isSMBPlaybackActive ?? false
}
@@ -299,11 +277,7 @@ final class AppEnvironment {
// Initialize Legacy Migration Service
self.legacyMigrationService = LegacyDataMigrationService(
instancesManager: instances,
basicAuthCredentialsManager: basicAuthCreds,
invidiousCredentialsManager: invidiousCreds,
pipedCredentialsManager: pipedCreds,
invidiousAPI: invidiousAPI,
pipedAPI: pipedAPI
httpClient: client
)
// Initialize Sources Settings
@@ -318,25 +292,9 @@ final class AppEnvironment {
// Wire up player controls layout service to player service (for preset-based settings)
player.setPlayerControlsLayoutService(layoutService)
// Cache active preset's center settings and keep them in sync
Task { await self.refreshActiveControlsSettings() }
for name: Notification.Name in [.playerControlsActivePresetDidChange, .playerControlsPresetsDidChange] {
controlsSettingsObservers.append(
NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
Task { @MainActor in
await self?.refreshActiveControlsSettings()
}
}
)
}
// Set up circular dependencies after all properties are initialized
bgRefreshManager.setAppEnvironment(self)
// Backfill credential flags for instances created before the flags
// existed, so existing setups publish them to iCloud without a re-login
backfillCredentialFlags()
// Log device capabilities on startup for debugging
HardwareCapabilities.shared.logCapabilities()
@@ -373,12 +331,6 @@ final class AppEnvironment {
// MARK: - Configuration
/// Refreshes the cached center-section settings from the active player controls preset.
func refreshActiveControlsSettings() async {
let layout = await playerControlsLayoutService.activeLayout()
activeControlsCenterSettings = layout.centerSettings
}
/// Updates the HTTP client's User-Agent configuration from current settings.
/// Call this after changing User-Agent related settings.
func updateUserAgent() {
@@ -422,53 +374,12 @@ final class AppEnvironment {
case .piped:
return pipedCredentialsManager
case .yatteeServer:
return basicAuthCredentialsManager
return yatteeServerCredentialsManager
default:
return nil
}
}
/// Backfills `usesBasicAuth`/`usesAccountLogin` on instances that were created
/// before these flags existed, based on credentials currently in the Keychain.
/// Only ever sets the flags a missing Keychain entry (e.g. right after a
/// reinstall) must not clear a previously recorded flag.
private func backfillCredentialFlags() {
for instance in instancesManager.instances {
if !instance.usesBasicAuth,
instance.supportsHTTPBasicAuthProxy,
basicAuthCredentialsManager.hasCredentials(for: instance) {
instancesManager.setUsesBasicAuth(true, for: instance)
}
if !instance.usesAccountLogin,
instance.supportsAuthentication,
let manager = credentialsManager(for: instance),
manager.isLoggedIn(for: instance) {
instancesManager.setUsesAccountLogin(true, for: instance)
}
}
}
/// Whether an instance is known to require credentials that are missing from
/// the Keychain (e.g. after reinstalling the app and importing sources from iCloud).
/// - Yattee Server always requires basic auth; other types require it when
/// `usesBasicAuth` was recorded.
/// - Invidious/Piped account logins are checked when `usesAccountLogin` was recorded.
func needsCredentials(for instance: Instance) -> Bool {
if instance.type == .yatteeServer || instance.usesBasicAuth,
!basicAuthCredentialsManager.hasCredentials(for: instance) {
return true
}
if instance.usesAccountLogin,
let manager = credentialsManager(for: instance),
!manager.isLoggedIn(for: instance) {
return true
}
return false
}
// MARK: - Preview/Testing Support
@MainActor

View File

@@ -1,12 +0,0 @@
import Foundation
enum AppGroup {
static let identifier = "group.stream.yattee.app.shared"
/// UserDefaults key holding an ordered [String] of enabled TopShelfSection raw values.
static let enabledSectionsKey = "topShelf.enabledSections"
static var defaults: UserDefaults {
UserDefaults(suiteName: identifier) ?? .standard
}
}

View File

@@ -20,17 +20,3 @@ struct FileCommands: Commands {
}
}
#endif
#if os(macOS)
/// App menu Settings item that opens the dedicated Settings window.
struct SettingsWindowMenuItem: View {
@Environment(\.openWindow) private var openWindow
var body: some View {
Button(String(localized: "menu.app.settings")) {
openWindow(id: "settings")
}
.keyboardShortcut(",", modifiers: [.command])
}
}
#endif

View File

@@ -145,17 +145,12 @@ final class InstancesManager {
}
saveInstances()
Task { await ProxyDetectionCache.shared.invalidate(instance: instance) }
}
func update(_ instance: Instance) {
if let index = instances.firstIndex(where: { $0.id == instance.id }) {
instances[index] = instance
saveInstances()
// Editing a source can change the proxy answer (URL change, toggle
// flip). Drop the cached auto-detect verdict so the next playback
// re-probes.
Task { await ProxyDetectionCache.shared.invalidate(instance: instance) }
}
}
@@ -174,26 +169,6 @@ final class InstancesManager {
}
}
/// Records whether an instance sits behind HTTP Basic Auth.
/// Persisted (and synced to iCloud) so missing Keychain credentials can be
/// detected after a reinstall.
func setUsesBasicAuth(_ value: Bool, for instance: Instance) {
guard let index = instances.firstIndex(where: { $0.id == instance.id }),
instances[index].usesBasicAuth != value else { return }
instances[index].usesBasicAuth = value
saveInstances()
}
/// Records whether the user has logged into an account on an instance.
/// Persisted (and synced to iCloud) so missing Keychain credentials can be
/// detected after a reinstall.
func setUsesAccountLogin(_ value: Bool, for instance: Instance) {
guard let index = instances.firstIndex(where: { $0.id == instance.id }),
instances[index].usesAccountLogin != value else { return }
instances[index].usesAccountLogin = value
saveInstances()
}
/// Sets the given instance as the primary (first) instance.
func setPrimary(_ instance: Instance) {
LoggingService.shared.debug("[InstancesManager] setPrimary called for: \(instance.displayName)", category: .general)

View File

@@ -1,25 +0,0 @@
//
// Notifications.swift
// Yattee
//
// App-wide notification names.
//
import Foundation
extension Notification.Name {
static let showSettings = Notification.Name("showSettings")
static let showOpenLinkSheet = Notification.Name("showOpenLinkSheet")
static let openDescriptionLink = Notification.Name("openDescriptionLink")
/// Posted when a URL shortener (bit.ly, etc.) has been resolved to an
/// ambiguous destination the app isn't certain it can play it, so the
/// user is prompted whether to try opening it in Yattee or in the browser.
/// `object` is the resolved `URL`.
static let promptResolvedShortLink = Notification.Name("promptResolvedShortLink")
/// Posted when a tapped link is not confidently a video (no YouTube /
/// PeerTube / direct-media match) but could potentially be extracted via
/// the Yattee server / yt-dlp. User is prompted whether to try extracting
/// or open it in the system browser instead.
/// `object` is the candidate `URL`.
static let promptAmbiguousExternalLink = Notification.Name("promptAmbiguousExternalLink")
}

View File

@@ -50,10 +50,10 @@ struct PlaybackCommands: Commands {
Divider()
// Seeking
seekBackwardButton
seekForwardButton
secondarySeekBackwardButton
secondarySeekForwardButton
seekBackward10Button
seekForward10Button
seekBackward30Button
seekForward30Button
Divider()
@@ -121,59 +121,41 @@ struct PlaybackCommands: Commands {
// MARK: - Seeking
/// Seek durations follow the active player controls preset, matching the
/// in-player arrow key shortcuts.
private var seekBackwardSeconds: Int {
appEnvironment.activeControlsCenterSettings.seekBackwardSeconds
}
private var seekForwardSeconds: Int {
appEnvironment.activeControlsCenterSettings.seekForwardSeconds
}
private var seekBackwardButton: some View {
private var seekBackward10Button: some View {
Button {
playerService.seekBackward(by: TimeInterval(seekBackwardSeconds))
playerService.seekBackward(by: 10)
} label: {
Text(String(localized: "menu.playback.seekBackward \(seekBackwardSeconds)"))
Text(String(localized: "menu.playback.seekBackward10"))
}
.keyboardShortcut(.leftArrow, modifiers: [.command])
.disabled(!hasActiveVideo)
}
private var seekForwardButton: some View {
private var seekForward10Button: some View {
Button {
playerService.seekForward(by: TimeInterval(seekForwardSeconds))
playerService.seekForward(by: 10)
} label: {
Text(String(localized: "menu.playback.seekForward \(seekForwardSeconds)"))
Text(String(localized: "menu.playback.seekForward10"))
}
.keyboardShortcut(.rightArrow, modifiers: [.command])
.disabled(!hasActiveVideo)
}
private var secondarySeekBackwardSeconds: Int {
appEnvironment.activeControlsCenterSettings.secondarySeekBackwardSeconds
}
private var secondarySeekForwardSeconds: Int {
appEnvironment.activeControlsCenterSettings.secondarySeekForwardSeconds
}
private var secondarySeekBackwardButton: some View {
private var seekBackward30Button: some View {
Button {
playerService.seekBackward(by: TimeInterval(secondarySeekBackwardSeconds))
playerService.seekBackward(by: 30)
} label: {
Text(String(localized: "menu.playback.seekBackward \(secondarySeekBackwardSeconds)"))
Text(String(localized: "menu.playback.seekBackward30"))
}
.keyboardShortcut(.leftArrow, modifiers: [.command, .shift])
.disabled(!hasActiveVideo)
}
private var secondarySeekForwardButton: some View {
private var seekForward30Button: some View {
Button {
playerService.seekForward(by: TimeInterval(secondarySeekForwardSeconds))
playerService.seekForward(by: 30)
} label: {
Text(String(localized: "menu.playback.seekForward \(secondarySeekForwardSeconds)"))
Text(String(localized: "menu.playback.seekForward30"))
}
.keyboardShortcut(.rightArrow, modifiers: [.command, .shift])
.disabled(!hasActiveVideo)
@@ -306,7 +288,7 @@ struct PlaybackCommands: Commands {
Text(String(localized: "menu.playback.pip"))
}
.keyboardShortcut("i", modifiers: [.command, .shift])
.disabled(!hasActiveVideo || !state.isPiPPossible || state.currentStream?.isAudioOnly == true)
.disabled(!hasActiveVideo || !state.isPiPPossible)
}
// MARK: - Close video button

View File

@@ -13,10 +13,6 @@ enum SettingsKey: String, CaseIterable {
// General
case theme
case accentColor
case customAccentColor
case accentColorDark
case customAccentColorDark
case useSeparateDarkAccentColor
case showWatchedCheckmark
// Playback
@@ -24,12 +20,10 @@ enum SettingsKey: String, CaseIterable {
case cellularQuality
case autoplay
case backgroundPlayback
case dashEnabled
case preferredAudioLanguage
case preferredSubtitlesLanguage
case resumeAction
case tvOSMenuButtonClosesVideo
case allowSoftwareDecodedFormats
case audioOnlyMode
// SponsorBlock
case sponsorBlockEnabled
@@ -46,14 +40,8 @@ enum SettingsKey: String, CaseIterable {
case deArrowAPIURL
case deArrowThumbnailAPIURL
// Short link resolution
case resolveShortLinksEnabled
// Platform-specific
case macPlayerSeparateWindow
case macPlayerFloating
case macControlsBarOffsetX // Normalized X offset of macOS control bar from default position
case macControlsBarOffsetY // Normalized Y offset of macOS control bar from default position
case macPlayerMode
case playerSheetAutoResize
case listStyle
@@ -72,17 +60,9 @@ enum SettingsKey: String, CaseIterable {
case homeShortcutOrder
case homeShortcutVisibility
case homeShortcutLayout
case homeShortcutCardStyle
case homeShortcutCardColor
case homeShortcutColorfulPalette
case homeShortcutCustomPaletteColors
case homeSectionOrder
case homeSectionVisibility
case homeSectionItemsLimit
case homeSectionLayout
// Top Shelf (tvOS)
case topShelfSections
// Tab Bar (compact size class)
case tabBarItemOrder
@@ -113,16 +93,11 @@ enum SettingsKey: String, CaseIterable {
// Advanced
case showAdvancedStreamDetails
case showPlayerAreaDebug
case showTVDebugButton
case verboseMPVLogging
case verboseRemoteControlLogging
case mpvBufferSeconds
case mpvUseEDLStreams
case zoomTransitionsEnabled
case tvMatchDisplayFrameRate
case tvMatchDisplayDynamicRange
case tvAudioDelayMs
case tvVideoSyncMode
// Details panel
case floatingDetailsPanelSide // Landscape only - which side the panel appears on
@@ -141,31 +116,9 @@ enum SettingsKey: String, CaseIterable {
case onboardingCompleted
/// Whether this key should have platform-specific prefixes.
/// Platform-specific keys are stored under a `iOS.` / `macOS.` / `tvOS.` prefix
/// in both UserDefaults and iCloud, so each platform family syncs independently.
var isPlatformSpecific: Bool {
switch self {
case .preferredQuality, .cellularQuality, .allowSoftwareDecodedFormats, .audioOnlyMode,
.macPlayerSeparateWindow, .macPlayerFloating, .listStyle,
.macControlsBarOffsetX, .macControlsBarOffsetY,
// Home layout different UI paradigms per platform
.homeShortcutOrder, .homeShortcutVisibility, .homeShortcutLayout, .homeShortcutCardStyle,
.homeShortcutCardColor, .homeShortcutColorfulPalette, .homeShortcutCustomPaletteColors,
.homeSectionOrder, .homeSectionVisibility, .homeSectionItemsLimit, .homeSectionLayout,
// Top Shelf tvOS only
.topShelfSections,
// Tab bar (compact size class) layout
.tabBarItemOrder, .tabBarItemVisibility, .tabBarStartupTab,
// Sidebar layout/selection
.sidebarMainItemOrder, .sidebarMainItemVisibility, .sidebarStartupTab,
.sidebarSourcesEnabled, .sidebarSourceSort, .sidebarSourcesLimitEnabled, .sidebarMaxSources,
.sidebarChannelsEnabled, .sidebarMaxChannels, .sidebarChannelSort, .sidebarChannelsLimitEnabled,
.sidebarPlaylistsEnabled, .sidebarMaxPlaylists, .sidebarPlaylistSort, .sidebarPlaylistsLimitEnabled,
// Player details panel iOS/iPadOS only, different on other platforms
.floatingDetailsPanelSide, .floatingDetailsPanelWidth,
.landscapeDetailsPanelVisible, .landscapeDetailsPanelPinned,
// Video swipe actions touch-gesture feature
.videoSwipeActionOrder, .videoSwipeActionVisibility:
case .preferredQuality, .cellularQuality, .macPlayerMode, .listStyle:
return true
default:
return false

View File

@@ -39,19 +39,6 @@ extension SettingsManager {
}
}
/// Whether to show the Debug button in the tvOS player bottom controls.
/// Opens the MPV debug overlay. Default is false (hidden).
var showTVDebugButton: Bool {
get {
if let cached = _showTVDebugButton { return cached }
return bool(for: .showTVDebugButton, default: false)
}
set {
_showTVDebugButton = newValue
set(newValue, for: .showTVDebugButton)
}
}
/// Whether verbose MPV rendering logging is enabled.
/// When enabled, logs detailed OpenGL context, framebuffer, and display link state
/// to help diagnose rendering issues. Default is false (disabled).
@@ -141,82 +128,6 @@ extension SettingsManager {
}
}
/// Whether tvOS should request the Apple TV switch its HDMI output to match the
/// playing video's frame rate. Has no effect unless the user also enables
/// "Match Content Frame Rate" in tvOS Settings Video and Audio.
/// Default is true on tvOS (no-op on other platforms).
var tvMatchDisplayFrameRate: Bool {
get {
if let cached = _tvMatchDisplayFrameRate { return cached }
return bool(for: .tvMatchDisplayFrameRate, default: false)
}
set {
_tvMatchDisplayFrameRate = newValue
set(newValue, for: .tvMatchDisplayFrameRate)
}
}
/// Fixed audio-pipeline offset (in milliseconds) applied as MPV's `audio-delay`
/// on tvOS. Useful for compensating fixed HDMI/AVR output latency where MPV's
/// internal `avsync` reads 0 but audio is perceptibly ahead/behind video.
/// Positive values shift audio later; negative values shift it earlier.
/// Default is 0 (no offset). Setting is local-only and tvOS-only.
var tvAudioDelayMs: Double {
get {
if let cached = _tvAudioDelayMs { return cached }
let raw = localDefaults.object(forKey: "tvAudioDelayMs") as? Double
return raw ?? 0
}
set {
_tvAudioDelayMs = newValue
localDefaults.set(newValue, forKey: "tvAudioDelayMs")
}
}
/// Read tvOS audio-delay setting from a nonisolated context (e.g. MPVClient init).
/// Returns milliseconds; convert to seconds before passing to MPV.
nonisolated static func tvAudioDelayMsSync() -> Double {
guard UserDefaults.standard.object(forKey: "tvAudioDelayMs") != nil else { return 0 }
return UserDefaults.standard.double(forKey: "tvAudioDelayMs")
}
/// MPV `video-sync` mode override for tvOS (debug toggle). The shipped default
/// remains `display-vdrop`; this is exposed so we can A/B alternative modes on
/// real hardware when investigating A/V sync issues.
var tvVideoSyncMode: TVVideoSyncMode {
get {
if let cached = _tvVideoSyncMode { return cached }
guard let raw = localDefaults.string(forKey: "tvVideoSyncMode"),
let mode = TVVideoSyncMode(rawValue: raw) else { return .displayVdrop }
return mode
}
set {
_tvVideoSyncMode = newValue
localDefaults.set(newValue.rawValue, forKey: "tvVideoSyncMode")
}
}
nonisolated static func tvVideoSyncModeSync() -> TVVideoSyncMode {
guard let raw = UserDefaults.standard.string(forKey: "tvVideoSyncMode"),
let mode = TVVideoSyncMode(rawValue: raw) else { return .displayVdrop }
return mode
}
/// Whether tvOS should request the Apple TV switch its HDMI output to match the
/// playing video's dynamic range (SDR / HDR10 / HLG). Has no effect unless the
/// user also enables "Match Content Dynamic Range" in tvOS Settings.
/// Default is true on tvOS (no-op on other platforms).
var tvMatchDisplayDynamicRange: Bool {
get {
if let cached = _tvMatchDisplayDynamicRange { return cached }
return bool(for: .tvMatchDisplayDynamicRange, default: false)
}
set {
_tvMatchDisplayDynamicRange = newValue
set(newValue, for: .tvMatchDisplayDynamicRange)
}
}
/// Whether zoom navigation transitions are enabled (iOS only).
/// When enabled, navigating to video/channel/playlist details shows a zoom animation
/// from the source thumbnail. Disable if experiencing visual glitches with swipe-back gestures.
@@ -297,9 +208,6 @@ extension SettingsManager {
/// Default is plain. Synced per-platform via iCloud.
var listStyle: VideoListStyle {
get {
#if os(tvOS)
return .plain
#else
if let cached = _listStyle { return cached }
guard let rawValue = string(for: .listStyle),
let style = VideoListStyle(rawValue: rawValue) else {
@@ -307,7 +215,6 @@ extension SettingsManager {
}
_listStyle = style
return style
#endif
}
set {
_listStyle = newValue

View File

@@ -335,11 +335,6 @@ extension SettingsManager {
// Copy all local settings to iCloud
for key in SettingsKey.allCases {
// Skip local-only keys (device-specific settings that shouldn't sync)
if key.isLocalOnly {
continue
}
let pKey = platformKey(key)
if let value = localDefaults.object(forKey: pKey) {
ubiquitousStore.set(value, forKey: pKey)
@@ -367,11 +362,6 @@ extension SettingsManager {
// Copy all iCloud settings to local defaults
for key in SettingsKey.allCases {
// Skip local-only keys (device-specific settings that shouldn't sync)
if key.isLocalOnly {
continue
}
// Skip protected keys that should preserve local values
if keysToPreserve.contains(key) {
continue
@@ -399,10 +389,6 @@ extension SettingsManager {
clearCache()
updateLastSyncTime()
#if !os(tvOS)
Self.applyTheme(theme)
#endif
}
/// Refreshes settings from iCloud by copying iCloud values to local storage.
@@ -459,11 +445,5 @@ extension SettingsManager {
// Clear caches to force re-read from local storage
clearCache()
// Re-apply the theme in case it was among the synced changes
// it is enforced at the window level, not via SwiftUI state.
#if !os(tvOS)
Self.applyTheme(theme)
#endif
}
}

View File

@@ -87,21 +87,4 @@ extension SettingsManager {
set(newValue, for: .deArrowThumbnailAPIURL)
}
}
// MARK: - Short Link Resolution
/// When enabled, taps on known URL shorteners (bit.ly, tinyurl, t.co, ) in
/// descriptions and comments follow the redirect and, if the destination is a
/// supported YouTube/PeerTube URL, open it in-app. Off by default because it
/// performs a network request to the shortener host on tap.
var resolveShortLinksEnabled: Bool {
get {
if let cached = _resolveShortLinksEnabled { return cached }
return bool(for: .resolveShortLinksEnabled, default: false)
}
set {
_resolveShortLinksEnabled = newValue
set(newValue, for: .resolveShortLinksEnabled)
}
}
}

View File

@@ -6,11 +6,8 @@
//
import Foundation
import SwiftUI
#if canImport(UIKit)
#if os(iOS)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
extension SettingsManager {
@@ -24,28 +21,9 @@ extension SettingsManager {
set {
_theme = newValue
set(newValue.rawValue, for: .theme)
Self.applyTheme(newValue)
}
}
/// Forces the theme onto the platform windows directly. SwiftUI's
/// `.preferredColorScheme` fails to revert from dark back to light/system
/// while a sheet is presented, and on macOS it never covered secondary
/// window scenes (Settings), so the override is applied at the
/// UIKit/AppKit level instead.
static func applyTheme(_ theme: AppTheme) {
#if os(iOS)
for scene in UIApplication.shared.connectedScenes {
guard let windowScene = scene as? UIWindowScene else { continue }
for window in windowScene.windows {
window.overrideUserInterfaceStyle = theme.userInterfaceStyle
}
}
#elseif os(macOS)
NSApp.appearance = theme.appearance
#endif
}
var accentColor: AccentColor {
get {
if let cached = _accentColor { return cached }
@@ -57,92 +35,9 @@ extension SettingsManager {
}
}
var customAccentColor: Color {
get {
let hex = _customAccentColor ?? string(for: .customAccentColor) ?? ""
return Color(hex: hex) ?? AccentColor.default.color
}
set {
let hex = newValue.toHexString()
_customAccentColor = hex
set(hex, for: .customAccentColor)
}
}
/// Whether the user picked a separate accent color for dark mode.
var useSeparateDarkAccentColor: Bool {
get {
if let cached = _useSeparateDarkAccentColor { return cached }
return bool(for: .useSeparateDarkAccentColor, default: false)
}
set {
_useSeparateDarkAccentColor = newValue
set(newValue, for: .useSeparateDarkAccentColor)
}
}
/// Accent color choice for dark mode. Falls back to the light selection until explicitly set.
var accentColorDark: AccentColor {
get {
if let cached = _accentColorDark { return cached }
if let raw = string(for: .accentColorDark), let value = AccentColor(rawValue: raw) {
return value
}
return accentColor
}
set {
_accentColorDark = newValue
set(newValue.rawValue, for: .accentColorDark)
}
}
/// Custom accent color for dark mode. Falls back to the light custom color until explicitly set.
var customAccentColorDark: Color {
get {
if let hex = _customAccentColorDark ?? string(for: .customAccentColorDark),
let color = Color(hex: hex) {
return color
}
return customAccentColor
}
set {
let hex = newValue.toHexString()
_customAccentColorDark = hex
set(hex, for: .customAccentColorDark)
}
}
/// Light (or shared) accent color, resolving `.custom` to the user-picked value.
private var resolvedLightAccentColor: Color {
accentColor == .custom ? customAccentColor : accentColor.color
}
/// Dark accent color, resolving `.custom` to the user-picked value.
private var resolvedDarkAccentColor: Color {
accentColorDark == .custom ? customAccentColorDark : accentColorDark.color
}
/// The effective accent color. When a separate dark color is enabled this is a
/// dynamic platform color that resolves per trait environment, so all consumers
/// (root tint and direct readers) adapt to light/dark automatically.
var resolvedAccentColor: Color {
let light = resolvedLightAccentColor
guard useSeparateDarkAccentColor else { return light }
let dark = resolvedDarkAccentColor
#if os(macOS)
return Color(nsColor: NSColor(name: nil, dynamicProvider: { appearance in
let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
return NSColor(isDark ? dark : light)
}))
#else
return Color(uiColor: UIColor { traits in
UIColor(traits.userInterfaceStyle == .dark ? dark : light)
})
#endif
}
// MARK: - App Icon Settings
// MARK: - App Icon Settings (iOS only)
#if os(iOS)
var appIcon: AppIcon {
get {
if let cached = _appIcon { return cached }
@@ -158,53 +53,14 @@ extension SettingsManager {
// Apply the icon change
Task { @MainActor in
#if os(iOS)
do {
try await UIApplication.shared.setAlternateIconName(newValue.alternateIconName)
} catch {
LoggingService.shared.error("Failed to set alternate icon: \(error)", category: .general)
}
#elseif os(macOS)
SettingsManager.applyMacAppIcon(newValue)
#endif
}
}
}
#if os(macOS)
static func applyMacAppIcon(_ icon: AppIcon) {
if icon == .default {
NSApp.applicationIconImage = nil
} else if let source = NSImage(named: icon.previewImageName) {
NSApp.applicationIconImage = makeMacIconImage(from: source)
}
}
/// Paints the source image onto a 1024×1024 canvas with the standard macOS
/// squircle mask and transparent padding so the Dock renders it like a
/// native app icon.
private static func makeMacIconImage(from source: NSImage) -> NSImage {
let canvasSize: CGFloat = 1024
// macOS icon grid: artwork occupies ~824×824 centered in a 1024 canvas,
// with a ~185pt corner radius on the masked rect.
let artworkSize: CGFloat = 824
let cornerRadius: CGFloat = 185
let inset = (canvasSize - artworkSize) / 2
let artworkRect = NSRect(x: inset, y: inset, width: artworkSize, height: artworkSize)
let image = NSImage(size: NSSize(width: canvasSize, height: canvasSize))
image.lockFocus()
let path = NSBezierPath(roundedRect: artworkRect, xRadius: cornerRadius, yRadius: cornerRadius)
path.addClip()
source.draw(in: artworkRect,
from: .zero,
operation: .sourceOver,
fraction: 1.0,
respectFlipped: true,
hints: [.interpolation: NSImageInterpolation.high.rawValue])
image.unlockFocus()
return image
}
#endif
/// Whether to show a checkmark badge on fully watched video thumbnails.
@@ -650,26 +506,6 @@ extension SettingsManager {
}
#endif
// MARK: - Video Tap Action (tvOS only)
#if os(tvOS)
/// Action to perform when clicking a video cell on tvOS. Default is openInfo.
var tvOSVideoTapAction: VideoTapAction {
get {
if let cached = _tvOSVideoTapAction { return cached }
guard let rawValue = localDefaults.string(forKey: "tvOSVideoTapAction"),
let action = VideoTapAction(rawValue: rawValue) else {
return .openInfo
}
return action
}
set {
_tvOSVideoTapAction = newValue
localDefaults.set(newValue.rawValue, forKey: "tvOSVideoTapAction")
}
}
#endif
// MARK: - Onboarding
/// Whether onboarding has been completed on this device.

View File

@@ -95,86 +95,6 @@ extension SettingsManager {
}
}
/// Layout for home shortcut cards (compact or regular). Default is regular.
var homeShortcutCardStyle: HomeShortcutCardStyle {
get {
if let cached = _homeShortcutCardStyle { return cached }
guard let rawValue = string(for: .homeShortcutCardStyle) else {
return .regular
}
return HomeShortcutCardStyle(rawValue: rawValue) ?? .regular
}
set {
_homeShortcutCardStyle = newValue
set(newValue.rawValue, for: .homeShortcutCardStyle)
}
}
/// Color emphasis for home shortcut cards (soft or vibrant), independent of
/// the layout style. Default is soft.
var homeShortcutCardColor: HomeShortcutCardColor {
get {
if let cached = _homeShortcutCardColor { return cached }
guard let rawValue = string(for: .homeShortcutCardColor) else {
return .soft
}
return HomeShortcutCardColor(rawValue: rawValue) ?? .soft
}
set {
_homeShortcutCardColor = newValue
set(newValue.rawValue, for: .homeShortcutCardColor)
}
}
/// Palette used by the "regular" card style. Colors are applied by grid
/// position. Default is Accent (the first palette option).
var homeShortcutColorfulPalette: HomeShortcutColorfulPalette {
get {
if let cached = _homeShortcutColorfulPalette { return cached }
guard let rawValue = string(for: .homeShortcutColorfulPalette) else {
return .accent
}
return HomeShortcutColorfulPalette(rawValue: rawValue) ?? .accent
}
set {
_homeShortcutColorfulPalette = newValue
set(newValue.rawValue, for: .homeShortcutColorfulPalette)
}
}
/// User-supplied hex colors for the custom "colorful" palette. Stored as a
/// single comma-separated string. Defaults to a starter set of colors.
var homeShortcutCustomPaletteColors: [String] {
get {
if let cached = _homeShortcutCustomPaletteColors { return cached }
guard let raw = string(for: .homeShortcutCustomPaletteColors) else {
return HomeShortcutColorfulPalette.customStarterColors
}
let colors = raw.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
return colors
}
set {
_homeShortcutCustomPaletteColors = newValue
let joined = newValue.map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }.joined(separator: ",")
set(joined, for: .homeShortcutCustomPaletteColors)
}
}
/// Layout mode for home sections (list or grid). Default is list on iOS/macOS, grid on tvOS.
var homeSectionLayout: HomeSectionLayout {
get {
if let cached = _homeSectionLayout { return cached }
guard let rawValue = string(for: .homeSectionLayout) else {
return HomeSectionLayout.platformDefault
}
return HomeSectionLayout(rawValue: rawValue) ?? HomeSectionLayout.platformDefault
}
set {
_homeSectionLayout = newValue
set(newValue.rawValue, for: .homeSectionLayout)
}
}
// MARK: - Home Section Settings
/// Ordered list of home sections. Default order is bookmarks, history, downloads.
@@ -586,6 +506,13 @@ extension SettingsManager {
return existingCards.contains(card.id) ? [] : [card]
}
/// Returns all available section items for a media source that are NOT already added.
func availableSections(for source: MediaSource) -> [HomeSectionItem] {
let section = HomeSectionItem.mediaSource(sourceID: source.id)
let existingSections = Set(homeSectionOrder.map { $0.id })
return existingSections.contains(section.id) ? [] : [section]
}
/// Returns all available cards across all media sources, grouped by source.
func allAvailableMediaSourceShortcuts(sources: [MediaSource]) -> [(source: MediaSource, cards: [HomeShortcutItem])] {
sources.compactMap { source in
@@ -594,6 +521,14 @@ extension SettingsManager {
}
}
/// Returns all available sections across all media sources, grouped by source.
func allAvailableMediaSourceSections(sources: [MediaSource]) -> [(source: MediaSource, sections: [HomeSectionItem])] {
sources.compactMap { source in
let sections = availableSections(for: source)
return sections.isEmpty ? nil : (source, sections)
}
}
/// Removes all Home items for media sources that no longer exist.
func cleanupOrphanedHomeMediaSourceItems(validSourceIDs: Set<UUID>) {
var hadOrphans = false
@@ -667,45 +602,6 @@ extension SettingsManager {
}
}
/// Removes ALL media-source Home *sections* (regardless of whether the source
/// still exists). Media sources are shortcuts-only now a media-source
/// "section" was just a browse link and the feature was removed. Idempotent:
/// only writes when something changed, so it's safe to call on every load and
/// it also cleans up media-source sections that sync in from an older device.
/// Leaves media-source *shortcuts* (`homeShortcutOrder`/`homeShortcutVisibility`) untouched.
func removeAllHomeMediaSourceSections() {
var didChange = false
var sectionOrder = homeSectionOrder
let originalCount = sectionOrder.count
sectionOrder.removeAll { item in
if case .mediaSource = item { return true }
return false
}
if sectionOrder.count != originalCount {
LoggingService.shared.logCloudKit("removeAllHomeMediaSourceSections: removed \(originalCount - sectionOrder.count) media-source sections")
homeSectionOrder = sectionOrder
didChange = true
}
var sectionVis = homeSectionVisibility
let orphanedKeys = sectionVis.keys.filter { item in
if case .mediaSource = item { return true }
return false
}
if !orphanedKeys.isEmpty {
for key in orphanedKeys {
sectionVis.removeValue(forKey: key)
}
homeSectionVisibility = sectionVis
didChange = true
}
if didChange {
LoggingService.shared.logCloudKit("removeAllHomeMediaSourceSections: cleaned up media-source sections")
}
}
// MARK: - Tab Bar Settings (Compact Size Class)
/// Ordered list of tab bar items. Default order is subscriptions first, then others.

View File

@@ -16,7 +16,6 @@ extension SettingsManager {
/// NOT synced to iCloud - local-only storage.
var customMPVOptions: [String: String] {
get {
if let cached = _customMPVOptions { return cached }
guard let data = localDefaults.data(forKey: "customMPVOptions"),
let options = try? JSONDecoder().decode([String: String].self, from: data) else {
return [:]
@@ -24,7 +23,6 @@ extension SettingsManager {
return options
}
set {
_customMPVOptions = newValue
if let data = try? JSONEncoder().encode(newValue) {
localDefaults.set(data, forKey: "customMPVOptions")
}

View File

@@ -1,74 +0,0 @@
//
// SettingsManager+Migration.swift
// Yattee
//
// One-shot migrations that move legacy unprefixed values under
// platform-specific keys when `SettingsKey.isPlatformSpecific` flips to true.
//
import Foundation
extension SettingsManager {
private static let migrationFlagKey = "didMigratePlatformSpecificLayoutKeys_v1"
// Mirrors `SettingsManager+CloudSync.protectedVisibilityKeys`. Kept in sync manually
// because that collection is fileprivate; the set is small and rarely changes.
private static let protectedKeysForMigration: Set<SettingsKey> = [
.homeShortcutVisibility,
.homeSectionVisibility,
.homeShortcutOrder,
.homeSectionOrder
]
/// Copies any legacy unprefixed values for keys that became platform-specific into their
/// new `iOS.` / `macOS.` / `tvOS.` slots, both locally and (if iCloud sync is on) in iCloud.
/// Leaves the legacy unprefixed keys in place so older builds on other devices still work.
func migrateLayoutKeysToPlatformPrefixed() {
guard !localDefaults.bool(forKey: Self.migrationFlagKey) else { return }
let keysNeedingMigration = SettingsKey.allCases.filter { $0.isPlatformSpecific }
let pushToCloud = iCloudSyncEnabled && syncSettings
for key in keysNeedingMigration {
let pKey = platformKey(key)
let legacyKey = key.rawValue
// Skip if the prefixed form already exists or if the legacy key is the same as the prefixed
// key (e.g. on a platform where `platformKey` didn't rewrite it, though that shouldn't happen
// for isPlatformSpecific keys).
guard pKey != legacyKey,
localDefaults.object(forKey: pKey) == nil,
let legacyValue = localDefaults.object(forKey: legacyKey)
else { continue }
localDefaults.set(legacyValue, forKey: pKey)
if Self.protectedKeysForMigration.contains(key) {
let legacyTimestampKey = "\(legacyKey)_modifiedAt"
let newTimestampKey = modifiedAtKey(for: key)
let legacyTimestamp = localDefaults.double(forKey: legacyTimestampKey)
if legacyTimestamp > 0, localDefaults.double(forKey: newTimestampKey) == 0 {
localDefaults.set(legacyTimestamp, forKey: newTimestampKey)
}
}
if pushToCloud {
ubiquitousStore.set(legacyValue, forKey: pKey)
if Self.protectedKeysForMigration.contains(key) {
let newTimestampKey = modifiedAtKey(for: key)
let timestamp = localDefaults.double(forKey: newTimestampKey)
if timestamp > 0 {
ubiquitousStore.set(timestamp, forKey: newTimestampKey)
}
}
}
}
if pushToCloud {
ubiquitousStore.synchronize()
}
localDefaults.set(true, forKey: Self.migrationFlagKey)
LoggingService.shared.logCloudKit("Migrated legacy layout keys to platform-prefixed storage")
}
}

View File

@@ -40,7 +40,7 @@ extension SettingsManager {
var backgroundPlaybackEnabled: Bool {
get {
if let cached = _backgroundPlaybackEnabled { return cached }
return bool(for: .backgroundPlayback, default: backgroundPlaybackDefault)
return bool(for: .backgroundPlayback, default: true)
}
set {
_backgroundPlaybackEnabled = newValue
@@ -48,49 +48,16 @@ extension SettingsManager {
}
}
private var backgroundPlaybackDefault: Bool {
#if os(tvOS)
return false
#else
return true
#endif
}
/// tvOS only: when enabled, the Siri remote Menu button closes the video
/// (clears queue, stops playback) instead of only collapsing the player.
/// When enabled, the explicit top-bar close button is hidden.
var tvOSMenuButtonClosesVideo: Bool {
/// Whether DASH streams are enabled (MPV only).
/// Disabled by default as DASH can be unreliable with some Invidious instances.
var dashEnabled: Bool {
get {
if let cached = _tvOSMenuButtonClosesVideo { return cached }
return bool(for: .tvOSMenuButtonClosesVideo, default: false)
if let cached = _dashEnabled { return cached }
return bool(for: .dashEnabled, default: false)
}
set {
_tvOSMenuButtonClosesVideo = newValue
set(newValue, for: .tvOSMenuButtonClosesVideo)
}
}
var allowSoftwareDecodedFormats: Bool {
get {
if let cached = _allowSoftwareDecodedFormats { return cached }
return bool(for: .allowSoftwareDecodedFormats, default: false)
}
set {
_allowSoftwareDecodedFormats = newValue
set(newValue, for: .allowSoftwareDecodedFormats)
}
}
/// Audio-only ("music") mode: when enabled, only the audio track is loaded
/// for every video until turned off. Persisted per platform.
var audioOnlyModeEnabled: Bool {
get {
if let cached = _audioOnlyModeEnabled { return cached }
return bool(for: .audioOnlyMode, default: false)
}
set {
_audioOnlyModeEnabled = newValue
set(newValue, for: .audioOnlyMode)
_dashEnabled = newValue
set(newValue, for: .dashEnabled)
}
}

View File

@@ -68,58 +68,14 @@ extension SettingsManager {
#endif
#if os(macOS)
/// Whether the expanded player opens in a separate window (vs. an inline sheet).
/// Default is `true`.
var macPlayerSeparateWindow: Bool {
var macPlayerMode: MacPlayerMode {
get {
if let cached = _macPlayerSeparateWindow { return cached }
return bool(for: .macPlayerSeparateWindow, default: true)
if let cached = _macPlayerMode { return cached }
return MacPlayerMode(rawValue: string(for: .macPlayerMode) ?? "") ?? .window
}
set {
_macPlayerSeparateWindow = newValue
set(newValue, for: .macPlayerSeparateWindow)
}
}
/// Whether the separate player window floats above other windows (always on top).
/// Toggled live from the player's top-bar pin button and remembered across sessions.
/// Default is `false`.
var macPlayerFloating: Bool {
get {
if let cached = _macPlayerFloating { return cached }
return bool(for: .macPlayerFloating, default: false)
}
set {
_macPlayerFloating = newValue
set(newValue, for: .macPlayerFloating)
}
}
/// Normalized X offset of the macOS floating control bar from its default
/// bottom-center position, stored as a fraction of the player container width.
/// 0 = default docked position.
var macControlsBarOffsetX: Double {
get {
if let cached = _macControlsBarOffsetX { return cached }
return double(for: .macControlsBarOffsetX)
}
set {
_macControlsBarOffsetX = newValue
set(newValue, for: .macControlsBarOffsetX)
}
}
/// Normalized Y offset of the macOS floating control bar from its default
/// bottom-center position, stored as a fraction of the player container height.
/// 0 = default docked position.
var macControlsBarOffsetY: Double {
get {
if let cached = _macControlsBarOffsetY { return cached }
return double(for: .macControlsBarOffsetY)
}
set {
_macControlsBarOffsetY = newValue
set(newValue, for: .macControlsBarOffsetY)
_macPlayerMode = newValue
set(newValue.rawValue, for: .macPlayerMode)
}
}

View File

@@ -1,43 +0,0 @@
//
// SettingsManager+TopShelf.swift
// Yattee
//
// tvOS Top Shelf settings.
//
import Foundation
extension SettingsManager {
/// Ordered list of sections visible in the tvOS Top Shelf.
/// Inclusion means the section is shown; absence hides it.
var topShelfSections: [TopShelfSection] {
get {
if let cached = _topShelfSections { return cached }
guard let data = data(for: .topShelfSections),
let saved = try? JSONDecoder().decode([TopShelfSection].self, from: data) else {
return TopShelfSection.defaultOrder
}
return saved
}
set {
_topShelfSections = newValue
if let data = try? JSONEncoder().encode(newValue) {
set(data, for: .topShelfSections)
}
let tsKey = modifiedAtKey(for: .topShelfSections)
let now = Date().timeIntervalSince1970
localDefaults.set(now, forKey: tsKey)
if iCloudSyncEnabled && syncSettings && !isInitialSyncPending {
ubiquitousStore.set(now, forKey: tsKey)
}
mirrorEnabledSectionsToAppGroup(newValue)
}
}
/// Mirrors the enabled-sections list to the App Group UserDefaults suite
/// so the tvOS Top Shelf extension can read the user's selection.
func mirrorEnabledSectionsToAppGroup(_ sections: [TopShelfSection]) {
let rawValues = sections.map(\.rawValue)
AppGroup.defaults.set(rawValues, forKey: AppGroup.enabledSectionsKey)
}
}

View File

@@ -7,11 +7,6 @@
import Foundation
import SwiftUI
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
// MARK: - Theme & Appearance
@@ -27,24 +22,6 @@ enum AppTheme: String, CaseIterable, Codable {
case .dark: return .dark
}
}
#if canImport(UIKit)
var userInterfaceStyle: UIUserInterfaceStyle {
switch self {
case .system: return .unspecified
case .light: return .light
case .dark: return .dark
}
}
#elseif canImport(AppKit)
var appearance: NSAppearance? {
switch self {
case .system: return nil
case .light: return NSAppearance(named: .aqua)
case .dark: return NSAppearance(named: .darkAqua)
}
}
#endif
}
enum AccentColor: String, CaseIterable, Codable {
@@ -58,20 +35,10 @@ enum AccentColor: String, CaseIterable, Codable {
case blue
case purple
case indigo
case custom
/// Fixed swatches shown in the settings grid; `.custom` is rendered
/// separately as a color picker, and `.indigo` is retired from the grid
/// but still resolves for users who selected it before it was removed.
static var presets: [AccentColor] {
allCases.filter { $0 != .custom && $0 != .indigo }
}
/// The color for `.custom` lives in settings storage resolve through
/// `SettingsManager.resolvedAccentColor` instead of this property.
var color: Color {
switch self {
case .default, .custom: return .blue // System default accent color
case .default: return .blue // System default accent color
case .red: return .red
case .pink: return .pink
case .orange: return .orange
@@ -85,6 +52,7 @@ enum AccentColor: String, CaseIterable, Codable {
}
}
#if os(iOS)
enum AppIcon: String, CaseIterable, Codable {
case `default`
case classic
@@ -121,6 +89,7 @@ enum AppIcon: String, CaseIterable, Codable {
}
}
}
#endif
// MARK: - Video Quality
@@ -234,6 +203,37 @@ enum DownloadQuality: String, CaseIterable, Codable, Sendable {
}
}
// MARK: - macOS Player Mode
#if os(macOS)
enum MacPlayerMode: String, CaseIterable, Codable {
case window
case floatingWindow
case inline
var displayName: String {
switch self {
case .window: return String(localized: "settings.playback.macOS.playerMode.window")
case .floatingWindow: return String(localized: "settings.playback.macOS.playerMode.floatingWindow")
case .inline: return String(localized: "settings.playback.macOS.playerMode.inline")
}
}
/// Whether this mode uses a separate window (vs sheet/inline)
var usesWindow: Bool {
switch self {
case .window, .floatingWindow: return true
case .inline: return false
}
}
/// Whether the window should float above other windows
var isFloating: Bool {
self == .floatingWindow
}
}
#endif
// MARK: - Haptic Feedback
/// Intensity levels for haptic feedback.

View File

@@ -1,30 +0,0 @@
import Foundation
/// Sections that can appear in the tvOS Top Shelf.
/// Stored ordered in `SettingsKey.topShelfSections` inclusion = visible.
enum TopShelfSection: String, Codable, CaseIterable, Identifiable, Sendable {
case continueWatching
case recentFeed
case recentBookmarks
var id: String { rawValue }
var localizedTitle: String {
switch self {
case .continueWatching: return String(localized: "home.section.continueWatching")
case .recentFeed: return String(localized: "home.section.feed")
case .recentBookmarks: return String(localized: "home.section.bookmarks")
}
}
/// UserDefaults key (under the app-group suite) holding the JSON snapshot for this section.
var snapshotKey: String {
switch self {
case .continueWatching: return "topShelf.continueWatching"
case .recentFeed: return "topShelf.recentFeed"
case .recentBookmarks: return "topShelf.recentBookmarks"
}
}
static let defaultOrder: [TopShelfSection] = [.continueWatching, .recentFeed, .recentBookmarks]
}

View File

@@ -28,23 +28,17 @@ final class SettingsManager {
// Theme
var _theme: AppTheme?
var _accentColor: AccentColor?
var _customAccentColor: String?
var _accentColorDark: AccentColor?
var _customAccentColorDark: String?
var _useSeparateDarkAccentColor: Bool?
var _showWatchedCheckmark: Bool?
// Playback
var _preferredQuality: VideoQuality?
var _cellularQuality: VideoQuality?
var _backgroundPlaybackEnabled: Bool?
var _dashEnabled: Bool?
var _preferredAudioLanguage: String?
var _preferredSubtitlesLanguage: String?
var _playerVolume: Float?
var _resumeAction: ResumeAction?
var _tvOSMenuButtonClosesVideo: Bool?
var _allowSoftwareDecodedFormats: Bool?
var _audioOnlyModeEnabled: Bool?
// SponsorBlock
var _sponsorBlockEnabled: Bool?
@@ -59,9 +53,6 @@ final class SettingsManager {
var _deArrowAPIURL: String?
var _deArrowThumbnailAPIURL: String?
// Short link resolution
var _resolveShortLinksEnabled: Bool?
// User Agent
var _customUserAgent: String?
var _randomizeUserAgentPerRequest: Bool?
@@ -77,10 +68,7 @@ final class SettingsManager {
var _preferPortraitBrowsing: Bool?
#endif
#if os(macOS)
var _macPlayerSeparateWindow: Bool?
var _macPlayerFloating: Bool?
var _macControlsBarOffsetX: Double?
var _macControlsBarOffsetY: Double?
var _macPlayerMode: MacPlayerMode?
var _playerSheetAutoResize: Bool?
#endif
@@ -116,20 +104,9 @@ final class SettingsManager {
var _homeShortcutOrder: [HomeShortcutItem]?
var _homeShortcutVisibility: [HomeShortcutItem: Bool]?
var _homeShortcutLayout: HomeShortcutLayout?
var _homeShortcutCardStyle: HomeShortcutCardStyle?
var _homeShortcutCardColor: HomeShortcutCardColor?
var _homeShortcutColorfulPalette: HomeShortcutColorfulPalette?
var _homeShortcutCustomPaletteColors: [String]?
var _homeSectionOrder: [HomeSectionItem]?
var _homeSectionVisibility: [HomeSectionItem: Bool]?
var _homeSectionItemsLimit: Int?
var _homeSectionLayout: HomeSectionLayout?
// Top Shelf (tvOS)
var _topShelfSections: [TopShelfSection]?
// Custom MPV options (local-only)
var _customMPVOptions: [String: String]?
// Tab bar settings (compact size class only - iOS)
var _tabBarItemOrder: [TabBarItem]?
@@ -163,16 +140,11 @@ final class SettingsManager {
// Advanced settings
var _showAdvancedStreamDetails: Bool?
var _showPlayerAreaDebug: Bool?
var _showTVDebugButton: Bool?
var _verboseMPVLogging: Bool?
var _verboseRemoteControlLogging: Bool?
var _mpvBufferSeconds: Double?
var _mpvUseEDLStreams: Bool?
var _zoomTransitionsEnabled: Bool?
var _tvMatchDisplayFrameRate: Bool?
var _tvMatchDisplayDynamicRange: Bool?
var _tvAudioDelayMs: Double?
var _tvVideoSyncMode: TVVideoSyncMode?
// Details panel settings
var _floatingDetailsPanelSide: FloatingPanelSide?
@@ -216,16 +188,13 @@ final class SettingsManager {
var _textAreaTapAction: VideoTapAction?
#endif
// Video tap action (tvOS only)
#if os(tvOS)
var _tvOSVideoTapAction: VideoTapAction?
#endif
// Player Controls settings (controlsButtonSize moved to preset)
// Appearance settings
var _listStyle: VideoListStyle?
#if os(iOS)
var _appIcon: AppIcon?
#endif
// Video Swipe Actions
#if !os(tvOS)
@@ -283,12 +252,6 @@ final class SettingsManager {
}
}
// One-shot migration: move legacy unprefixed values for keys that became
// platform-specific into their new iOS./macOS./tvOS. slots. Must run before
// the initial iCloud refresh so it can seed the prefixed iCloud slot from the
// current local value before any remote data is read.
migrateLayoutKeysToPlatformPrefixed()
// Initial sync from iCloud to local storage (async to avoid blocking app launch)
// This ensures local defaults have the latest iCloud values before any reads.
// While sync is pending, suppress iCloud writes from set() to prevent stale
@@ -422,21 +385,15 @@ final class SettingsManager {
func clearCache() {
_theme = nil
_accentColor = nil
_customAccentColor = nil
_accentColorDark = nil
_customAccentColorDark = nil
_useSeparateDarkAccentColor = nil
_showWatchedCheckmark = nil
_preferredQuality = nil
_cellularQuality = nil
_backgroundPlaybackEnabled = nil
_dashEnabled = nil
_preferredAudioLanguage = nil
_preferredSubtitlesLanguage = nil
_playerVolume = nil
_resumeAction = nil
_tvOSMenuButtonClosesVideo = nil
_allowSoftwareDecodedFormats = nil
_audioOnlyModeEnabled = nil
_sponsorBlockEnabled = nil
_sponsorBlockCategories = nil
_sponsorBlockAPIURL = nil
@@ -446,7 +403,6 @@ final class SettingsManager {
_deArrowReplaceThumbnails = nil
_deArrowAPIURL = nil
_deArrowThumbnailAPIURL = nil
_resolveShortLinksEnabled = nil
_customUserAgent = nil
_randomizeUserAgentPerRequest = nil
_feedCacheValidityMinutes = nil
@@ -470,10 +426,7 @@ final class SettingsManager {
_syncSearchHistory = nil
_searchHistoryLimit = nil
#if os(macOS)
_macPlayerSeparateWindow = nil
_macPlayerFloating = nil
_macControlsBarOffsetX = nil
_macControlsBarOffsetY = nil
_macPlayerMode = nil
_playerSheetAutoResize = nil
#endif
// miniPlayerShowVideo and miniPlayerVideoTapAction moved to preset
@@ -483,15 +436,9 @@ final class SettingsManager {
_homeShortcutOrder = nil
_homeShortcutVisibility = nil
_homeShortcutLayout = nil
_homeShortcutCardStyle = nil
_homeShortcutCardColor = nil
_homeShortcutColorfulPalette = nil
_homeShortcutCustomPaletteColors = nil
_homeSectionOrder = nil
_homeSectionVisibility = nil
_homeSectionItemsLimit = nil
_homeSectionLayout = nil
_topShelfSections = nil
_tabBarItemOrder = nil
_tabBarItemVisibility = nil
_sidebarMainItemOrder = nil
@@ -512,16 +459,11 @@ final class SettingsManager {
_sidebarPlaylistsLimitEnabled = nil
_showAdvancedStreamDetails = nil
_showPlayerAreaDebug = nil
_showTVDebugButton = nil
_verboseMPVLogging = nil
_verboseRemoteControlLogging = nil
_mpvBufferSeconds = nil
_mpvUseEDLStreams = nil
_zoomTransitionsEnabled = nil
_tvMatchDisplayFrameRate = nil
_tvMatchDisplayDynamicRange = nil
_tvAudioDelayMs = nil
_tvVideoSyncMode = nil
_floatingDetailsPanelSide = nil
_floatingDetailsPanelWidth = nil
_landscapeDetailsPanelVisible = nil
@@ -548,9 +490,6 @@ final class SettingsManager {
_thumbnailTapAction = nil
_textAreaTapAction = nil
#endif
#if os(tvOS)
_tvOSVideoTapAction = nil
#endif
_listStyle = nil
#if os(iOS)
_appIcon = nil

View File

@@ -217,7 +217,7 @@ final class Bookmark {
publishedText: publishedText,
viewCount: viewCount,
likeCount: nil,
thumbnails: Thumbnail.fallbackChain(for: thumbnailURL),
thumbnails: thumbnailURL.map { [Thumbnail(url: $0, width: nil, height: nil)] } ?? [],
isLive: isLive,
isUpcoming: false,
scheduledStartTime: nil

View File

@@ -120,56 +120,26 @@ extension DataManager {
return try? modelContext.fetch(descriptor).first
}
/// Gets all bookmarks matching a video ID. The same ID can exist
/// under multiple source scopes; callers that care about a specific
/// source must pick the matching entity themselves.
func bookmarks(forVideoID videoID: String) -> [Bookmark] {
let descriptor = FetchDescriptor<Bookmark>(
predicate: #Predicate { $0.videoID == videoID }
)
return (try? modelContext.fetch(descriptor)) ?? []
}
/// Deletes a single bookmark without queueing a CloudKit deletion.
/// Used by CloudKitSyncEngine when applying remote deletions.
func deleteBookmark(_ bookmark: Bookmark) {
let videoID = bookmark.videoID
modelContext.delete(bookmark)
save()
// Keep the fast-lookup cache accurate; another scope may still
// have a bookmark with this video ID
if self.bookmark(for: videoID) == nil {
cachedBookmarkedVideoIDs.remove(videoID)
}
TopShelfSnapshotWriter.writeBookmarks(dataManager: self)
}
/// Inserts a bookmark into the database.
/// Used by CloudKitSyncEngine for applying remote bookmarks.
func insertBookmark(_ bookmark: Bookmark) {
// Check for duplicates within the same source scope - the same
// video ID can legitimately exist under different sources
// Check for duplicates
let videoID = bookmark.videoID
let descriptor = FetchDescriptor<Bookmark>(
predicate: #Predicate { $0.videoID == videoID }
)
let scopeSuffix = bookmark.sourceScopeSuffix
do {
let existing = try modelContext.fetch(descriptor)
if !existing.contains(where: { $0.sourceScopeSuffix == scopeSuffix }) {
if existing.isEmpty {
modelContext.insert(bookmark)
save()
cachedBookmarkedVideoIDs.insert(videoID)
}
} catch {
// Insert anyway if we can't check
modelContext.insert(bookmark)
save()
cachedBookmarkedVideoIDs.insert(videoID)
}
TopShelfSnapshotWriter.writeBookmarks(dataManager: self)
}
/// Updates bookmark tags and note for a video.
@@ -209,17 +179,3 @@ extension DataManager {
}
}
}
// MARK: - Source Scope
private extension Bookmark {
/// Record-name scope suffix used to distinguish same-ID entities across sources.
var sourceScopeSuffix: String {
SourceScope.from(
sourceRawValue: sourceRawValue,
globalProvider: globalProvider,
instanceURLString: instanceURLString,
externalExtractor: externalExtractor
).recordNameSuffix
}
}

View File

@@ -115,22 +115,6 @@ extension DataManager {
}
}
/// Gets all notification settings matching a channel ID. The same ID can
/// exist under multiple source scopes.
func allChannelNotificationSettings(forChannelID channelID: String) -> [ChannelNotificationSettings] {
let descriptor = FetchDescriptor<ChannelNotificationSettings>(
predicate: #Predicate { $0.channelID == channelID }
)
return (try? modelContext.fetch(descriptor)) ?? []
}
/// Deletes a single notification settings record without queueing a
/// CloudKit deletion. Used by CloudKitSyncEngine when applying remote deletions.
func deleteChannelNotificationSettings(_ settings: ChannelNotificationSettings) {
modelContext.delete(settings)
save()
}
/// Deletes notification settings for a channel.
/// - Parameter channelID: The channel ID to delete settings for.
func deleteNotificationSettings(for channelID: String) {

View File

@@ -304,30 +304,10 @@ extension DataManager {
return try? modelContext.fetch(descriptor).first
}
/// Gets all recent channels matching a channel ID. The same ID can exist
/// under multiple source scopes.
func recentChannelEntries(forChannelID channelID: String) -> [RecentChannel] {
let descriptor = FetchDescriptor<RecentChannel>(
predicate: #Predicate { $0.channelID == channelID }
)
return (try? modelContext.fetch(descriptor)) ?? []
}
/// Deletes a recent channel without queueing a CloudKit deletion.
/// Used by CloudKitSyncEngine when applying remote deletions.
func deleteRecentChannelEntry(_ channel: RecentChannel) {
modelContext.delete(channel)
save()
NotificationCenter.default.post(name: .recentChannelsDidChange, object: nil)
}
/// Inserts a recent channel (for CloudKit sync).
func insertRecentChannel(_ recentChannel: RecentChannel) {
// Check for duplicates within the same source scope - the same
// channel ID can legitimately exist under different sources
let scopeSuffix = recentChannel.sourceScopeSuffix
let existing = recentChannelEntries(forChannelID: recentChannel.channelID)
if !existing.contains(where: { $0.sourceScopeSuffix == scopeSuffix }) {
// Check for duplicates by channelID
if recentChannelEntry(forChannelID: recentChannel.channelID) == nil {
modelContext.insert(recentChannel)
save()
}
@@ -358,7 +338,7 @@ extension DataManager {
existing.title = playlist.title
existing.authorName = playlist.authorName
existing.videoCount = playlist.videoCount
existing.thumbnailURLString = RecentPlaylist.reliableThumbnailURLString(playlist.thumbnailURL)
existing.thumbnailURLString = playlist.thumbnailURL?.absoluteString
savedEntry = existing
} else {
// Create new entry
@@ -491,58 +471,12 @@ extension DataManager {
return try? modelContext.fetch(descriptor).first
}
/// Gets all recent playlists matching a playlist ID. The same ID can
/// exist under multiple source scopes.
func recentPlaylistEntries(forPlaylistID playlistID: String) -> [RecentPlaylist] {
let descriptor = FetchDescriptor<RecentPlaylist>(
predicate: #Predicate { $0.playlistID == playlistID }
)
return (try? modelContext.fetch(descriptor)) ?? []
}
/// Deletes a recent playlist without queueing a CloudKit deletion.
/// Used by CloudKitSyncEngine when applying remote deletions.
func deleteRecentPlaylistEntry(_ playlist: RecentPlaylist) {
modelContext.delete(playlist)
save()
NotificationCenter.default.post(name: .recentPlaylistsDidChange, object: nil)
}
/// Inserts a recent playlist (for CloudKit sync).
func insertRecentPlaylist(_ recentPlaylist: RecentPlaylist) {
// Check for duplicates within the same source scope - the same
// playlist ID can legitimately exist under different sources
let scopeSuffix = recentPlaylist.sourceScopeSuffix
let existing = recentPlaylistEntries(forPlaylistID: recentPlaylist.playlistID)
if !existing.contains(where: { $0.sourceScopeSuffix == scopeSuffix }) {
// Check for duplicates by playlistID
if recentPlaylistEntry(forPlaylistID: recentPlaylist.playlistID) == nil {
modelContext.insert(recentPlaylist)
save()
}
}
}
// MARK: - Source Scope
private extension RecentChannel {
/// Record-name scope suffix used to distinguish same-ID entities across sources.
var sourceScopeSuffix: String {
SourceScope.from(
sourceRawValue: sourceRawValue,
globalProvider: nil,
instanceURLString: instanceURLString,
externalExtractor: nil
).recordNameSuffix
}
}
private extension RecentPlaylist {
/// Record-name scope suffix used to distinguish same-ID entities across sources.
var sourceScopeSuffix: String {
SourceScope.from(
sourceRawValue: sourceRawValue,
globalProvider: nil,
instanceURLString: instanceURLString,
externalExtractor: nil
).recordNameSuffix
}
}

View File

@@ -200,16 +200,6 @@ extension DataManager {
}
}
/// Gets all subscriptions matching a channel ID. The same ID can exist
/// under multiple source scopes; callers that care about a specific
/// source must pick the matching entity themselves.
func subscriptions(forChannelID channelID: String) -> [Subscription] {
let descriptor = FetchDescriptor<Subscription>(
predicate: #Predicate { $0.channelID == channelID }
)
return (try? modelContext.fetch(descriptor)) ?? []
}
/// Gets all subscriptions.
func subscriptions() -> [Subscription] {
let descriptor = FetchDescriptor<Subscription>(
@@ -229,17 +219,15 @@ extension DataManager {
/// Inserts a subscription into the database.
/// Used by SubscriptionService for caching server subscriptions locally.
func insertSubscription(_ subscription: Subscription) {
// Check for duplicates within the same source scope - the same
// channel ID can legitimately exist under different sources
// Check for duplicates
let channelID = subscription.channelID
let descriptor = FetchDescriptor<Subscription>(
predicate: #Predicate { $0.channelID == channelID }
)
let scopeSuffix = subscription.sourceScopeSuffix
do {
let existing = try modelContext.fetch(descriptor)
if !existing.contains(where: { $0.sourceScopeSuffix == scopeSuffix }) {
if existing.isEmpty {
modelContext.insert(subscription)
save()
}
@@ -291,45 +279,6 @@ extension DataManager {
}
}
/// Deletes all locally stored subscriptions, including their iCloud copies.
/// Server-account subscriptions (Invidious/Piped) are unaffected.
func deleteAllSubscriptions() {
let allSubscriptions = subscriptions()
guard !allSubscriptions.isEmpty else { return }
var deleteInfo: [(channelID: String, scope: SourceScope)] = []
for subscription in allSubscriptions {
let scope = SourceScope.from(
sourceRawValue: subscription.sourceRawValue,
globalProvider: subscription.providerName,
instanceURLString: subscription.instanceURLString,
externalExtractor: nil
)
deleteInfo.append((subscription.channelID, scope))
modelContext.delete(subscription)
}
save()
for info in deleteInfo {
cloudKitSync?.queueSubscriptionDelete(channelID: info.channelID, scope: info.scope)
}
SubscriptionFeedCache.shared.invalidate()
let change = SubscriptionChange(
addedSubscriptions: [],
removedChannelIDs: deleteInfo.map(\.channelID)
)
NotificationCenter.default.post(
name: .subscriptionsDidChange,
object: nil,
userInfo: [SubscriptionChange.userInfoKey: change]
)
LoggingService.shared.info("Deleted all \(deleteInfo.count) local subscriptions", category: .general)
}
/// Returns the total count of subscriptions.
var subscriptionCount: Int {
let descriptor = FetchDescriptor<Subscription>()
@@ -445,17 +394,3 @@ extension DataManager {
}
}
}
// MARK: - Source Scope
private extension Subscription {
/// Record-name scope suffix used to distinguish same-ID entities across sources.
var sourceScopeSuffix: String {
SourceScope.from(
sourceRawValue: sourceRawValue,
globalProvider: providerName,
instanceURLString: instanceURLString,
externalExtractor: nil
).recordNameSuffix
}
}

View File

@@ -13,13 +13,8 @@ extension DataManager {
/// Records or updates watch progress locally without triggering iCloud sync.
/// Use this for frequent updates during playback to avoid unnecessary sync overhead.
///
/// - Parameter isLive: Set by the player when the active stream is live but the
/// `Video` metadata does not say so (some sources hardcode `isLive: false`).
/// Live views are still recorded in history, but without a resume position.
func updateWatchProgressLocal(for video: Video, seconds: TimeInterval, duration: TimeInterval? = nil, isLive: Bool = false) {
func updateWatchProgressLocal(for video: Video, seconds: TimeInterval, duration: TimeInterval? = nil) {
let videoID = video.id.videoID
let isLiveContent = video.isLive || isLive
let descriptor = FetchDescriptor<WatchEntry>(
predicate: #Predicate { $0.videoID == videoID }
)
@@ -27,30 +22,16 @@ extension DataManager {
do {
let existing = try modelContext.fetch(descriptor)
if let existingEntry = existing.first {
if isLiveContent {
existingEntry.recordLiveWatch()
} else {
// Clear the flag first - updateProgress's auto-finish check reads
// `progress`, which is hard-0 while isLive is still set
existingEntry.isLive = false
existingEntry.updateProgress(seconds: seconds, duration: duration)
}
save()
} else {
let newEntry = WatchEntry.from(video: video)
if !isLiveContent {
newEntry.watchedSeconds = seconds
if let duration, duration > 0, newEntry.duration == 0 {
newEntry.duration = duration
}
} else {
newEntry.isLive = true
}
modelContext.insert(newEntry)
save()
// Notify HomeView when a new entry is inserted (not on every progress update)
NotificationCenter.default.post(name: .watchHistoryDidChange, object: nil)
}
save()
// Note: No CloudKit queueing - use updateWatchProgress() when sync is needed
} catch {
LoggingService.shared.logCloudKitError("Failed to update watch progress locally", error: error)
@@ -59,10 +40,9 @@ extension DataManager {
/// Records or updates watch progress for a video and queues for iCloud sync.
/// Use this when video closes or switches to sync the final progress.
func updateWatchProgress(for video: Video, seconds: TimeInterval, duration: TimeInterval? = nil, isLive: Bool = false) {
func updateWatchProgress(for video: Video, seconds: TimeInterval, duration: TimeInterval? = nil) {
// Find existing entry or create new one
let videoID = video.id.videoID
let isLiveContent = video.isLive || isLive
let descriptor = FetchDescriptor<WatchEntry>(
predicate: #Predicate { $0.videoID == videoID }
)
@@ -71,25 +51,14 @@ extension DataManager {
let existing = try modelContext.fetch(descriptor)
let entry: WatchEntry
if let existingEntry = existing.first {
if isLiveContent {
existingEntry.recordLiveWatch()
} else {
// Clear the flag first - updateProgress's auto-finish check reads
// `progress`, which is hard-0 while isLive is still set
existingEntry.isLive = false
existingEntry.updateProgress(seconds: seconds, duration: duration)
}
entry = existingEntry
} else {
let newEntry = WatchEntry.from(video: video)
if !isLiveContent {
newEntry.watchedSeconds = seconds
if let duration, duration > 0, newEntry.duration == 0 {
newEntry.duration = duration
}
} else {
newEntry.isLive = true
}
modelContext.insert(newEntry)
entry = newEntry
}
@@ -97,7 +66,6 @@ extension DataManager {
// Queue for CloudKit sync
cloudKitSync?.queueWatchEntrySave(entry)
TopShelfSnapshotWriter.writeContinueWatching(dataManager: self)
} catch {
LoggingService.shared.logCloudKitError("Failed to update watch progress", error: error)
}
@@ -157,38 +125,18 @@ extension DataManager {
return try? modelContext.fetch(descriptor).first
}
/// Gets all watch entries matching a video ID. The same ID can exist
/// under multiple source scopes; callers that care about a specific
/// source must pick the matching entity themselves.
func watchEntries(forVideoID videoID: String) -> [WatchEntry] {
let descriptor = FetchDescriptor<WatchEntry>(
predicate: #Predicate { $0.videoID == videoID }
)
return (try? modelContext.fetch(descriptor)) ?? []
}
/// Deletes a single watch entry without queueing a CloudKit deletion.
/// Used by CloudKitSyncEngine when applying remote deletions.
func deleteWatchEntry(_ entry: WatchEntry) {
modelContext.delete(entry)
save()
TopShelfSnapshotWriter.writeContinueWatching(dataManager: self)
}
/// Inserts a watch entry into the database.
/// Used by CloudKitSyncEngine for applying remote watch history.
func insertWatchEntry(_ watchEntry: WatchEntry) {
// Check for duplicates within the same source scope - the same
// video ID can legitimately exist under different sources
// Check for duplicates
let videoID = watchEntry.videoID
let descriptor = FetchDescriptor<WatchEntry>(
predicate: #Predicate { $0.videoID == videoID }
)
let scopeSuffix = watchEntry.sourceScopeSuffix
do {
let existing = try modelContext.fetch(descriptor)
if !existing.contains(where: { $0.sourceScopeSuffix == scopeSuffix }) {
if existing.isEmpty {
modelContext.insert(watchEntry)
save()
}
@@ -197,7 +145,6 @@ extension DataManager {
modelContext.insert(watchEntry)
save()
}
TopShelfSnapshotWriter.writeContinueWatching(dataManager: self)
}
/// Clears all watch history.
@@ -424,17 +371,3 @@ extension DataManager {
}
}
}
// MARK: - Source Scope
private extension WatchEntry {
/// Record-name scope suffix used to distinguish same-ID entities across sources.
var sourceScopeSuffix: String {
SourceScope.from(
sourceRawValue: sourceRawValue,
globalProvider: globalProvider,
instanceURLString: instanceURLString,
externalExtractor: externalExtractor
).recordNameSuffix
}
}

View File

@@ -75,16 +75,10 @@ final class LocalPlaylist {
}
/// The first video's thumbnail URL for display.
///
/// Rewritten to the always-available `hqdefault` variant because the stored
/// URL is the best advertised quality (often `maxresdefault`), which 404s
/// for many older videos and would leave the cover blank.
var thumbnailURL: URL? {
Thumbnail.reliableURL(
for: (items ?? []).sorted { $0.sortOrder < $1.sortOrder }
(items ?? []).sorted { $0.sortOrder < $1.sortOrder }
.first?
.thumbnailURL
)
}
/// Sorted items by order.
@@ -255,7 +249,7 @@ extension LocalPlaylistItem {
publishedText: nil,
viewCount: nil,
likeCount: nil,
thumbnails: Thumbnail.fallbackChain(for: thumbnailURL),
thumbnails: thumbnailURL.map { [Thumbnail(url: $0, quality: .medium)] } ?? [],
isLive: isLive,
isUpcoming: false,
scheduledStartTime: nil

View File

@@ -57,18 +57,10 @@ final class RecentPlaylist {
title: playlist.title,
authorName: playlist.authorName,
videoCount: playlist.videoCount,
thumbnailURLString: reliableThumbnailURLString(playlist.thumbnailURL)
thumbnailURLString: playlist.thumbnailURL?.absoluteString
)
}
/// Rewrites YouTube `/vi/ID/...` thumbnails to the always-available `hqdefault.jpg`
/// variant: recent playlist cards render a single URL without fallback, and
/// higher-quality variants (`maxresdefault`/`sddefault`) 404 for many older videos.
static func reliableThumbnailURLString(_ url: URL?) -> String? {
guard let url else { return nil }
return (Thumbnail.reliableURL(for: url) ?? url).absoluteString
}
private static func extractSourceInfo(from source: ContentSource) -> (String, String?) {
switch source {
case .global:

View File

@@ -51,10 +51,6 @@ final class WatchEntry {
/// Thumbnail URL string.
var thumbnailURLString: String?
/// Whether this entry was recorded for a live stream.
/// Live entries never carry a resume position - see `recordLiveWatch()`.
var isLive: Bool = false
// MARK: - Watch Progress
/// Last watched position in seconds.
@@ -90,8 +86,7 @@ final class WatchEntry {
duration: TimeInterval,
thumbnailURLString: String? = nil,
watchedSeconds: TimeInterval = 0,
isFinished: Bool = false,
isLive: Bool = false
isFinished: Bool = false
) {
self.videoID = videoID
self.sourceRawValue = sourceRawValue
@@ -107,7 +102,6 @@ final class WatchEntry {
self.thumbnailURLString = thumbnailURLString
self.watchedSeconds = watchedSeconds
self.isFinished = isFinished
self.isLive = isLive
self.createdAt = Date()
self.updatedAt = Date()
}
@@ -142,10 +136,8 @@ final class WatchEntry {
}
/// Watch progress as a percentage (0.0 to 1.0).
/// Always 0 for live streams - their playback position is relative to the
/// live edge, so it does not describe progress through a fixed timeline.
var progress: Double {
guard !isLive, duration > 0 else { return 0 }
guard duration > 0 else { return 0 }
return min(watchedSeconds / duration, 1.0)
}
@@ -196,20 +188,6 @@ final class WatchEntry {
}
}
/// Records a live-stream view without storing a resume position.
/// Live playback positions are relative to the live edge, so keeping them
/// would make the app offer to "continue" from a meaningless timestamp.
/// Also clears state a previous non-live save may have left behind, which
/// heals entries wrongly marked finished by a drifting HLS duration.
func recordLiveWatch() {
isLive = true
watchedSeconds = 0
duration = 0
isFinished = false
finishedAt = nil
updatedAt = Date()
}
/// Marks the video as finished.
func markAsFinished() {
isFinished = true
@@ -241,8 +219,8 @@ extension WatchEntry {
publishedText: nil,
viewCount: nil,
likeCount: nil,
thumbnails: Thumbnail.fallbackChain(for: thumbnailURL),
isLive: isLive,
thumbnails: thumbnailURL.map { [Thumbnail(url: $0, quality: .medium)] } ?? [],
isLive: false,
isUpcoming: false,
scheduledStartTime: nil
)
@@ -282,11 +260,8 @@ extension WatchEntry {
title: video.title,
authorName: video.author.name,
authorID: video.author.id,
// Live videos report no usable duration - Invidious sends 0 and Piped
// uses -1 as its live sentinel, which must never reach storage.
duration: video.isLive ? 0 : max(0, video.duration),
thumbnailURLString: video.bestThumbnail?.url.absoluteString,
isLive: video.isLive
duration: video.duration,
thumbnailURLString: video.bestThumbnail?.url.absoluteString
)
}
}

View File

@@ -1,71 +0,0 @@
//
// Color+Hex.swift
// Yattee
//
// Hex-string parsing/formatting for SwiftUI Color, plus the environment value
// used to drive position-based colorful shortcut cards.
//
import SwiftUI
extension Color {
/// Creates a color from a `#RRGGBB` / `RRGGBB` hex string (also accepts
/// `#RGB` shorthand and an optional `AA`/`AARRGGBB`/`RRGGBBAA` alpha).
/// Returns `nil` for anything it can't parse so bad input is simply skipped.
init?(hex: String) {
var cleaned = hex.trimmingCharacters(in: .whitespacesAndNewlines)
if cleaned.hasPrefix("#") {
cleaned.removeFirst()
}
guard !cleaned.isEmpty, cleaned.allSatisfy({ $0.isHexDigit }) else { return nil }
// Expand #RGB shorthand to #RRGGBB.
if cleaned.count == 3 {
cleaned = cleaned.map { "\($0)\($0)" }.joined()
}
guard let value = UInt64(cleaned, radix: 16) else { return nil }
let r, g, b, a: Double
switch cleaned.count {
case 6: // RRGGBB
r = Double((value & 0xFF0000) >> 16) / 255
g = Double((value & 0x00FF00) >> 8) / 255
b = Double(value & 0x0000FF) / 255
a = 1
case 8: // RRGGBBAA
r = Double((value & 0xFF00_0000) >> 24) / 255
g = Double((value & 0x00FF_0000) >> 16) / 255
b = Double((value & 0x0000_FF00) >> 8) / 255
a = Double(value & 0x0000_00FF) / 255
default:
return nil
}
self.init(red: r, green: g, blue: b, opacity: a)
}
/// Formats the resolved color as an uppercase `#RRGGBB` string.
func toHexString() -> String {
let resolved = resolve(in: EnvironmentValues())
let r = Int((max(0, min(1, resolved.red)) * 255).rounded())
let g = Int((max(0, min(1, resolved.green)) * 255).rounded())
let b = Int((max(0, min(1, resolved.blue)) * 255).rounded())
return String(format: "#%02X%02X%02X", r, g, b)
}
}
// MARK: - Environment: position-based colorful color
private struct HomeShortcutColorfulColorKey: EnvironmentKey {
static let defaultValue: Color? = nil
}
extension EnvironmentValues {
/// The colorful-style fill color resolved from the shortcut's grid position.
/// When set, it overrides a card's fixed `colorfulColor`.
var homeShortcutColorfulColor: Color? {
get { self[HomeShortcutColorfulColorKey.self] }
set { self[HomeShortcutColorfulColorKey.self] = newValue }
}
}

View File

@@ -1,16 +0,0 @@
import Foundation
extension TimeInterval {
/// Formats as "M:SS" or "H:MM:SS" when hours > 0.
var formattedAsTimestamp: String {
let totalSeconds = Int(max(0, self))
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
let seconds = totalSeconds % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
}
return String(format: "%d:%02d", minutes, seconds)
}
}

View File

@@ -1,50 +0,0 @@
//
// ToolbarContent+Close.swift
// Yattee
//
// Shared sheet-dismiss toolbar item.
//
import SwiftUI
/// Standard sheet-dismiss toolbar item.
///
/// On macOS this renders a native text button (e.g. "Close"), which fits the desktop
/// convention. On iOS/tvOS it renders the compact icon-only xmark used by the rest of
/// the app's sheets.
///
/// Placement defaults to `.confirmationAction`; only the *label* changes per platform,
/// so it can be dropped into existing `.toolbar { }` blocks without changing layout.
@ToolbarContentBuilder
func sheetCloseToolbarItem(
placement: ToolbarItemPlacement = .confirmationAction,
titleKey: LocalizedStringResource = "common.close",
identifier: String? = nil,
action: @escaping () -> Void
) -> some ToolbarContent {
ToolbarItem(placement: placement) {
sheetCloseButton(titleKey: titleKey, identifier: identifier, action: action)
}
}
@ViewBuilder
private func sheetCloseButton(
titleKey: LocalizedStringResource,
identifier: String?,
action: @escaping () -> Void
) -> some View {
let button = Button(role: .cancel, action: action) {
#if os(macOS)
Text(String(localized: titleKey))
#else
Label(String(localized: titleKey), systemImage: "xmark")
.labelStyle(.iconOnly)
#endif
}
if let identifier {
button.accessibilityIdentifier(identifier)
} else {
button
}
}

View File

@@ -1,24 +0,0 @@
//
// View+SoftScrollEdgeEffect.swift
// Yattee
//
// iOS 27 changed the default scroll edge effect style from soft to hard,
// which draws a sharp dividing line under the toolbar. Views that extend
// a banner or gradient beneath the toolbar (channel header, video info)
// need the soft style so the artwork stays cleanly visible.
//
import SwiftUI
extension View {
/// Forces the soft (blur/fade) scroll edge effect on the top edge
/// for scroll views in this hierarchy. No-op before iOS 26/macOS 26.
@ViewBuilder
func softTopScrollEdgeEffect() -> some View {
if #available(iOS 26, macOS 26, tvOS 26, *) {
scrollEdgeEffectStyle(.soft, for: .top)
} else {
self
}
}
}

View File

@@ -9,14 +9,6 @@
import SwiftUI
#if os(iOS)
/// Whether the iOS binary is running on a Mac (Catalyst or "Designed for iPad").
/// Zoom transitions trap inside UIKit's _UIZoomTransitionController when a sheet
/// is dismissed on the Mac presentation path, so they must stay off there.
private let zoomTransitionsRunningOnMac: Bool =
ProcessInfo.processInfo.isMacCatalystApp || ProcessInfo.processInfo.isiOSAppOnMac
#endif
// MARK: - Environment Keys
/// Environment key to pass the navigation transition namespace through the view hierarchy.
@@ -57,7 +49,7 @@ struct ZoomTransitionSourceModifier<ID: Hashable>: ViewModifier {
func body(content: Content) -> some View {
#if os(iOS)
if zoomTransitionsEnabled, !zoomTransitionsRunningOnMac, let namespace {
if zoomTransitionsEnabled, let namespace {
content
.matchedTransitionSource(id: id, in: namespace)
} else {
@@ -83,7 +75,7 @@ struct ZoomTransitionDestinationModifier<ID: Hashable>: ViewModifier {
func body(content: Content) -> some View {
#if os(iOS)
if zoomTransitionsEnabled, !zoomTransitionsRunningOnMac, let namespace {
if zoomTransitionsEnabled, let namespace {
content
.navigationTransition(.zoom(sourceID: id, in: namespace))
} else {

View File

@@ -1,17 +0,0 @@
//
// ObjCExceptionHandler.h
// Yattee
//
// Catches ObjC NSExceptions that Swift cannot handle natively.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
/// Executes a block and catches any NSException thrown.
/// Returns YES if the block executed without throwing, NO if an exception was caught.
/// If an exception is caught, it is returned via the outException parameter.
BOOL tryCatchObjCException(void (NS_NOESCAPE ^block)(void), NSException *_Nullable *_Nullable outException);
NS_ASSUME_NONNULL_END

View File

@@ -1,20 +0,0 @@
//
// ObjCExceptionHandler.m
// Yattee
//
// Catches ObjC NSExceptions that Swift cannot handle natively.
//
#import "ObjCExceptionHandler.h"
BOOL tryCatchObjCException(void (NS_NOESCAPE ^block)(void), NSException *_Nullable *_Nullable outException) {
@try {
block();
return YES;
} @catch (NSException *exception) {
if (outException) {
*outException = exception;
}
return NO;
}
}

View File

@@ -1,47 +0,0 @@
<?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>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>stream.yattee.app.feedRefresh</string>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>stream.yattee.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>yattee</string>
</array>
</dict>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSBonjourServices</key>
<array>
<string>_yattee._tcp</string>
<string>_webdav._tcp</string>
<string>_webdavs._tcp</string>
<string>_smb._tcp</string>
</array>
<key>NSUserActivityTypes</key>
<array>
<string>stream.yattee.app.activity</string>
</array>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>remote-notification</string>
<string>fetch</string>
</array>
</dict>
</plist>

View File

@@ -4,15 +4,6 @@
<dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<!-- Sparkle auto-updates (Developer ID / Homebrew cask build only; ignored on App Store + iOS + tvOS) -->
<key>SUFeedURL</key>
<string>https://dl.yattee.stream/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>BLtSfi3Epsl97XpMy734PhlbscxWwWpi6moT/S++A+4=</string>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>SUEnableInstallerLauncherService</key>
<true/>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>stream.yattee.app.feedRefresh</string>

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,6 @@ struct CachedChannelData: Codable {
let thumbnailURL: URL?
let bannerURL: URL?
let subscriberCount: Int?
let description: String?
/// In-memory cache of author data from video detail API responses.
@MainActor
@@ -33,25 +32,21 @@ struct CachedChannelData: Codable {
.appendingPathComponent("authors.json")
}
init(name: String, thumbnailURL: URL?, bannerURL: URL?, subscriberCount: Int?, description: String? = nil) {
init(name: String, thumbnailURL: URL?, bannerURL: URL?, subscriberCount: Int?) {
self.name = name
self.thumbnailURL = thumbnailURL
self.bannerURL = bannerURL
self.subscriberCount = subscriberCount
self.description = description
}
@MainActor
static func cacheAuthor(_ author: Author) {
guard !author.id.isEmpty, !author.name.isEmpty else { return }
loadFromDiskIfNeeded()
let existing = authorCache[author.id]
guard author.thumbnailURL != nil || author.subscriberCount != nil else { return }
authorCache[author.id] = CachedChannelData(
name: author.name,
thumbnailURL: author.thumbnailURL ?? existing?.thumbnailURL,
bannerURL: existing?.bannerURL,
subscriberCount: author.subscriberCount ?? existing?.subscriberCount,
description: existing?.description
thumbnailURL: author.thumbnailURL,
bannerURL: nil,
subscriberCount: author.subscriberCount
)
// Evict oldest entries if over limit
@@ -106,7 +101,6 @@ struct CachedChannelData: Codable {
thumbnailURL = subscription.avatarURL
bannerURL = subscription.bannerURL
subscriberCount = subscription.subscriberCount
description = subscription.channelDescription
}
init(from recentChannel: RecentChannel) {
@@ -114,7 +108,6 @@ struct CachedChannelData: Codable {
thumbnailURL = recentChannel.thumbnailURLString.flatMap { URL(string: $0) }
bannerURL = nil // RecentChannel doesn't store banner
subscriberCount = recentChannel.subscriberCount
description = nil
}
/// Load cached data for a channel ID from Subscription or RecentChannel.

View File

@@ -18,11 +18,6 @@ struct Caption: Identifiable, Codable, Hashable, Sendable {
/// The URL to fetch the caption content
let url: URL
/// Original filename of a user-picked external subtitle file.
/// When set, it is used as the display name so two picked files with the
/// same language remain distinguishable from API captions and each other.
var pickedFileName: String? = nil
/// Whether this is an auto-generated caption
var isAutoGenerated: Bool {
label.contains("auto-generated")
@@ -42,9 +37,6 @@ struct Caption: Identifiable, Codable, Hashable, Sendable {
/// Formatted display name for the caption
var displayName: String {
if let pickedFileName {
return pickedFileName
}
// Try to get localized language name (AUTO badge shown separately in UI)
if let localizedName = Locale.current.localizedString(forLanguageCode: baseLanguageCode) {
return localizedName

View File

@@ -73,19 +73,6 @@ struct Instance: Identifiable, Codable, Hashable, Sendable {
/// Whether to allow invalid/self-signed SSL certificates.
var allowInvalidCertificates: Bool
/// Whether to route video streams through this instance instead of connecting directly to YouTube CDN.
var proxiesVideos: Bool
/// Whether this instance sits behind an HTTP Basic Auth reverse proxy.
/// Set when basic auth credentials are stored, so a missing Keychain entry
/// (e.g. after reinstalling and importing sources from iCloud) can be detected.
/// Yattee Server always requires basic auth regardless of this flag.
var usesBasicAuth: Bool
/// Whether the user has logged into an account on this instance (Invidious/Piped).
/// Set on login, so a missing Keychain credential can be detected after reinstall.
var usesAccountLogin: Bool
// MARK: - Initialization
init(
@@ -96,10 +83,7 @@ struct Instance: Identifiable, Codable, Hashable, Sendable {
isEnabled: Bool = true,
dateAdded: Date = Date(),
apiKey: String? = nil,
allowInvalidCertificates: Bool = false,
proxiesVideos: Bool = false,
usesBasicAuth: Bool = false,
usesAccountLogin: Bool = false
allowInvalidCertificates: Bool = false
) {
self.id = id
self.type = type
@@ -109,24 +93,6 @@ struct Instance: Identifiable, Codable, Hashable, Sendable {
self.dateAdded = dateAdded
self.apiKey = apiKey
self.allowInvalidCertificates = allowInvalidCertificates
self.proxiesVideos = proxiesVideos
self.usesBasicAuth = usesBasicAuth
self.usesAccountLogin = usesAccountLogin
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
type = try container.decode(InstanceType.self, forKey: .type)
url = try container.decode(URL.self, forKey: .url)
name = try container.decodeIfPresent(String.self, forKey: .name)
isEnabled = try container.decode(Bool.self, forKey: .isEnabled)
dateAdded = try container.decode(Date.self, forKey: .dateAdded)
apiKey = try container.decodeIfPresent(String.self, forKey: .apiKey)
allowInvalidCertificates = try container.decode(Bool.self, forKey: .allowInvalidCertificates)
proxiesVideos = try container.decodeIfPresent(Bool.self, forKey: .proxiesVideos) ?? false
usesBasicAuth = try container.decodeIfPresent(Bool.self, forKey: .usesBasicAuth) ?? false
usesAccountLogin = try container.decodeIfPresent(Bool.self, forKey: .usesAccountLogin) ?? false
}
// MARK: - Computed Properties
@@ -135,17 +101,6 @@ struct Instance: Identifiable, Codable, Hashable, Sendable {
name ?? url.host ?? url.absoluteString
}
/// Returns the URL string with embedded credentials stripped for safe display in the UI.
var displayURL: String {
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
components.user != nil else {
return url.absoluteString
}
components.user = nil
components.password = nil
return components.url?.absoluteString ?? url.absoluteString
}
var contentSource: ContentSource {
type.contentSource(for: url)
}
@@ -193,27 +148,6 @@ extension Instance {
var supportsPopular: Bool {
type == .invidious || type == .yatteeServer
}
/// Whether this instance supports proxying video streams through itself.
var supportsVideoProxying: Bool {
type == .invidious || type == .piped || type == .yatteeServer
}
/// Whether this instance can sit behind an HTTP Basic Auth reverse proxy.
/// Piped is excluded: its session token is sent in the same `Authorization`
/// header that the proxy would consume, so logged-in features can't coexist
/// with proxy credentials.
var supportsHTTPBasicAuthProxy: Bool {
type.supportsHTTPBasicAuthProxy
}
}
extension InstanceType {
/// See `Instance.supportsHTTPBasicAuthProxy`. Type-only variant for flows
/// (e.g., AddRemoteServer detection) that don't yet have an `Instance`.
var supportsHTTPBasicAuthProxy: Bool {
self != .piped
}
}
// MARK: - Instance Validation
@@ -311,6 +245,10 @@ extension Instance {
components.path = String(components.path.dropLast())
}
// Strip embedded credentials (security best practice)
components.user = nil
components.password = nil
return components.url
}
}

View File

@@ -1,165 +0,0 @@
//
// MPVTrack.swift
// Yattee
//
// Model representing one entry of mpv's `track-list` property.
//
import Foundation
/// A single track reported by mpv's `track-list` property (embedded or external).
struct MPVTrack: Equatable, Sendable, Identifiable, Decodable {
enum TrackType: String, Decodable, Sendable {
case video
case audio
case sub
}
/// mpv track id unique only within a track type.
let trackID: Int
let type: TrackType
let title: String?
let lang: String?
let isDefault: Bool
let isForced: Bool
/// True for tracks loaded via `sub-add`/`audio-add` (external files).
let isExternal: Bool
/// Whether mpv currently plays this track.
let isSelected: Bool
/// Cover-art pseudo video tracks.
let isAlbumArt: Bool
let codec: String?
let channelCount: Int?
let sampleRate: Int?
let width: Int?
let height: Int?
let fps: Double?
/// Identifiable across types mpv ids collide between audio/sub/video.
var id: String { "\(type.rawValue):\(trackID)" }
private enum CodingKeys: String, CodingKey {
case trackID = "id"
case type
case title
case lang
case isDefault = "default"
case isForced = "forced"
case isExternal = "external"
case isSelected = "selected"
case isAlbumArt = "albumart"
case codec
case channelCount = "demux-channel-count"
case sampleRate = "demux-samplerate"
case width = "demux-w"
case height = "demux-h"
case fps = "demux-fps"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
trackID = try container.decode(Int.self, forKey: .trackID)
type = try container.decode(TrackType.self, forKey: .type)
title = try container.decodeIfPresent(String.self, forKey: .title)
lang = try container.decodeIfPresent(String.self, forKey: .lang)
isDefault = try container.decodeIfPresent(Bool.self, forKey: .isDefault) ?? false
isForced = try container.decodeIfPresent(Bool.self, forKey: .isForced) ?? false
isExternal = try container.decodeIfPresent(Bool.self, forKey: .isExternal) ?? false
isSelected = try container.decodeIfPresent(Bool.self, forKey: .isSelected) ?? false
isAlbumArt = try container.decodeIfPresent(Bool.self, forKey: .isAlbumArt) ?? false
codec = try container.decodeIfPresent(String.self, forKey: .codec)
channelCount = try container.decodeIfPresent(Int.self, forKey: .channelCount)
sampleRate = try container.decodeIfPresent(Int.self, forKey: .sampleRate)
width = try container.decodeIfPresent(Int.self, forKey: .width)
height = try container.decodeIfPresent(Int.self, forKey: .height)
fps = try container.decodeIfPresent(Double.self, forKey: .fps)
}
init(
trackID: Int,
type: TrackType,
title: String? = nil,
lang: String? = nil,
isDefault: Bool = false,
isForced: Bool = false,
isExternal: Bool = false,
isSelected: Bool = false,
isAlbumArt: Bool = false,
codec: String? = nil,
channelCount: Int? = nil,
sampleRate: Int? = nil,
width: Int? = nil,
height: Int? = nil,
fps: Double? = nil
) {
self.trackID = trackID
self.type = type
self.title = title
self.lang = lang
self.isDefault = isDefault
self.isForced = isForced
self.isExternal = isExternal
self.isSelected = isSelected
self.isAlbumArt = isAlbumArt
self.codec = codec
self.channelCount = channelCount
self.sampleRate = sampleRate
self.width = width
self.height = height
self.fps = fps
}
/// Base language code normalized to the 2-letter form when possible, so
/// Matroska 3-letter codes ("eng") match preference values ("en").
var baseLanguageCode: String? {
guard let lang, !lang.isEmpty, lang != "und" else { return nil }
var code = lang.lowercased()
if let hyphenIndex = code.firstIndex(of: "-") {
code = String(code[..<hyphenIndex])
}
guard code.count == 3 else { return code }
// ISO 639-2 -> 639-1 where a 2-letter code exists ("eng" -> "en").
return Locale.LanguageCode(code).identifier(.alpha2) ?? code
}
/// "Director's Commentary", "English", or a numbered fallback.
var displayName: String {
if let title, !title.isEmpty {
return title
}
if let baseLanguageCode,
let localized = Locale.current.localizedString(forLanguageCode: baseLanguageCode) {
return localized
}
return String(localized: "player.track.number \(trackID)")
}
/// Whether this track's language matches a user preference code like "en".
func matchesLanguage(_ preferredCode: String?) -> Bool {
guard let preferredCode, !preferredCode.isEmpty,
let baseLanguageCode else { return false }
var preferred = preferredCode.lowercased()
if let hyphenIndex = preferred.firstIndex(of: "-") {
preferred = String(preferred[..<hyphenIndex])
}
if preferred.count == 3 {
preferred = Locale.LanguageCode(preferred).identifier(.alpha2) ?? preferred
}
return preferred == baseLanguageCode
}
/// Secondary detail line for advanced mode, e.g. "eac3 · 6ch · 48 kHz".
var detailText: String? {
var parts: [String] = []
if let codec, !codec.isEmpty {
parts.append(codec)
}
if let channelCount {
parts.append("\(channelCount)ch")
}
if let sampleRate {
parts.append("\(sampleRate / 1000) kHz")
}
return parts.isEmpty ? nil : parts.joined(separator: " · ")
}
}

View File

@@ -67,14 +67,7 @@ struct MediaFile: Identifiable, Hashable, Sendable {
/// Full URL to this file.
var url: URL {
// For local folders, the persisted `source.url` may point at a stale app
// container path after iOS reinstall/restore. Prefer the freshly resolved
// bookmark URL stored in `LocalFolderURLResolver` when available.
if source.type == .localFolder,
let resolved = LocalFolderURLResolver.resolvedURL(for: source.id) {
return resolved.appendingPathComponent(path)
}
return source.url.appendingPathComponent(path)
source.url.appendingPathComponent(path)
}
/// File extension (lowercase).

View File

@@ -15,8 +15,6 @@ enum SidebarItem: Hashable, Identifiable {
case sources
case settings
case nowPlaying
case openURL
case remoteControl
// MARK: - Dynamic Channel Items
case channel(channelID: String, name: String, source: ContentSource)
@@ -36,8 +34,6 @@ enum SidebarItem: Hashable, Identifiable {
case downloads
case subscriptionsFeed
case manageChannels
case playlistsList
case continueWatching
// MARK: - Identifiable
@@ -53,10 +49,6 @@ enum SidebarItem: Hashable, Identifiable {
return "settings"
case .nowPlaying:
return "now-playing"
case .openURL:
return "open-url"
case .remoteControl:
return "remote-control"
case .channel(let channelID, _, let source):
return "channel-\(source.provider)-\(channelID)"
case .playlist(let id, _):
@@ -75,10 +67,6 @@ enum SidebarItem: Hashable, Identifiable {
return "subscriptions-feed"
case .manageChannels:
return "manage-channels"
case .playlistsList:
return "playlists-list"
case .continueWatching:
return "continue-watching"
}
}
@@ -96,10 +84,6 @@ enum SidebarItem: Hashable, Identifiable {
return String(localized: "tabs.settings")
case .nowPlaying:
return String(localized: "sidebar.nowPlaying")
case .openURL:
return String(localized: "sidebar.mainItem.openURL")
case .remoteControl:
return String(localized: "sidebar.mainItem.remoteControl")
case .channel(_, let name, _):
return name
case .playlist(_, let title):
@@ -118,10 +102,6 @@ enum SidebarItem: Hashable, Identifiable {
return String(localized: "home.subscriptions.title")
case .manageChannels:
return String(localized: "sidebar.manageChannels")
case .playlistsList:
return String(localized: "home.playlists.title")
case .continueWatching:
return String(localized: "home.continueWatching.title")
}
}
@@ -137,10 +117,6 @@ enum SidebarItem: Hashable, Identifiable {
return "gear"
case .nowPlaying:
return "play.circle.fill"
case .openURL:
return "link"
case .remoteControl:
return "antenna.radiowaves.left.and.right"
case .channel:
return "person.circle"
case .playlist:
@@ -159,10 +135,6 @@ enum SidebarItem: Hashable, Identifiable {
return "play.square.stack.fill"
case .manageChannels:
return "person.2"
case .playlistsList:
return "list.bullet.rectangle"
case .continueWatching:
return "play.circle"
}
}
@@ -172,7 +144,7 @@ enum SidebarItem: Hashable, Identifiable {
/// Returns nil for items that are root views (home, search) which don't push.
func navigationDestination() -> NavigationDestination? {
switch self {
case .home, .search, .sources, .settings, .nowPlaying, .openURL, .remoteControl:
case .home, .search, .sources, .settings, .nowPlaying:
// These are root tabs, not push destinations
return nil
case .channel(let channelID, _, let source):
@@ -194,10 +166,6 @@ enum SidebarItem: Hashable, Identifiable {
return .subscriptionsFeed
case .manageChannels:
return .manageChannels
case .playlistsList:
return .playlists
case .continueWatching:
return .continueWatching
}
}
@@ -206,7 +174,7 @@ enum SidebarItem: Hashable, Identifiable {
/// Whether this is a fixed navigation item (always visible).
var isFixedNavigation: Bool {
switch self {
case .home, .search, .sources, .settings, .nowPlaying, .openURL, .remoteControl:
case .home, .search, .sources, .settings, .nowPlaying:
return true
default:
return false

View File

@@ -16,23 +16,18 @@ enum SidebarMainItem: String, CaseIterable, Codable, Identifiable, Sendable {
case history
case downloads
case channels
case playlists
case sources
case settings
case openURL
case remoteControl
case continueWatching
var id: String { rawValue }
/// Default order for sidebar main items.
static var defaultOrder: [SidebarMainItem] {
[.search, .home, .subscriptions, .bookmarks, .history, .channels, .playlists, .sources, .openURL, .remoteControl, .downloads, .continueWatching, .settings]
[.search, .home, .subscriptions, .bookmarks, .history, .channels, .sources, .downloads, .settings]
}
/// Default visibility (all visible except subscriptions and channels).
static var defaultVisibility: [SidebarMainItem: Bool] {
#if os(tvOS)
[
.search: true,
.home: true,
@@ -41,30 +36,9 @@ enum SidebarMainItem: String, CaseIterable, Codable, Identifiable, Sendable {
.history: false,
.downloads: true,
.channels: false,
.playlists: false,
.sources: true,
.settings: true,
.openURL: false,
.remoteControl: true,
.continueWatching: false
.settings: true
]
#else
[
.search: true,
.home: true,
.subscriptions: false,
.bookmarks: false,
.history: false,
.downloads: true,
.channels: false,
.playlists: false,
.sources: true,
.settings: true,
.openURL: false,
.remoteControl: false,
.continueWatching: false
]
#endif
}
/// SF Symbol icon name.
@@ -77,12 +51,8 @@ enum SidebarMainItem: String, CaseIterable, Codable, Identifiable, Sendable {
case .history: "clock"
case .downloads: "arrow.down.circle"
case .channels: "person.2"
case .playlists: "list.bullet.rectangle"
case .sources: "server.rack"
case .settings: "gear"
case .openURL: "link"
case .remoteControl: "antenna.radiowaves.left.and.right"
case .continueWatching: "play.circle"
}
}
@@ -96,12 +66,8 @@ enum SidebarMainItem: String, CaseIterable, Codable, Identifiable, Sendable {
case .history: String(localized: "sidebar.mainItem.history")
case .downloads: String(localized: "sidebar.mainItem.downloads")
case .channels: String(localized: "sidebar.mainItem.channels")
case .playlists: String(localized: "sidebar.mainItem.playlists")
case .sources: String(localized: "sidebar.mainItem.sources")
case .settings: String(localized: "sidebar.mainItem.settings")
case .openURL: String(localized: "sidebar.mainItem.openURL")
case .remoteControl: String(localized: "sidebar.mainItem.remoteControl")
case .continueWatching: String(localized: "sidebar.mainItem.continueWatching")
}
}
@@ -124,12 +90,6 @@ enum SidebarMainItem: String, CaseIterable, Codable, Identifiable, Sendable {
#else
return true
#endif
case .settings:
#if os(macOS)
return false
#else
return true
#endif
default:
return true
}
@@ -148,12 +108,8 @@ enum SidebarMainItem: String, CaseIterable, Codable, Identifiable, Sendable {
case .history: return TabBarItem.history.rawValue
case .downloads: return TabBarItem.downloads.rawValue
case .channels: return TabBarItem.channels.rawValue
case .playlists: return TabBarItem.playlists.rawValue
case .sources: return TabBarItem.sources.rawValue
case .settings: return TabBarItem.settings.rawValue
case .openURL: return "open-url"
case .remoteControl: return "remote-control"
case .continueWatching: return "continue-watching"
}
}
@@ -167,12 +123,8 @@ enum SidebarMainItem: String, CaseIterable, Codable, Identifiable, Sendable {
case .history: return .history
case .downloads: return .downloads
case .channels: return .manageChannels
case .playlists: return .playlistsList
case .sources: return .sources
case .settings: return .settings
case .openURL: return .openURL
case .remoteControl: return .remoteControl
case .continueWatching: return .continueWatching
}
}
@@ -182,12 +134,11 @@ enum SidebarMainItem: String, CaseIterable, Codable, Identifiable, Sendable {
case .subscriptions: self = .subscriptions
case .channels: self = .channels
case .bookmarks: self = .bookmarks
case .playlists: self = .playlists
case .playlists: return nil // No direct mapping - playlists isn't a SidebarMainItem
case .history: self = .history
case .downloads: self = .downloads
case .sources: self = .sources
case .settings: self = .settings
case .continueWatching: self = .continueWatching
}
}
}

View File

@@ -17,18 +17,17 @@ enum TabBarItem: String, CaseIterable, Codable, Identifiable, Sendable {
case downloads
case sources
case settings
case continueWatching
var id: String { rawValue }
/// Default order for tab bar items.
static var defaultOrder: [TabBarItem] {
[.subscriptions, .channels, .bookmarks, .playlists, .history, .continueWatching, .sources, .downloads, .settings]
[.subscriptions, .channels, .bookmarks, .playlists, .history, .sources, .downloads, .settings]
}
/// Default visibility (only subscriptions visible by default).
static var defaultVisibility: [TabBarItem: Bool] {
[.subscriptions: false, .channels: false, .bookmarks: false, .playlists: false, .history: false, .downloads: true, .sources: true, .settings: false, .continueWatching: false]
[.subscriptions: false, .channels: false, .bookmarks: false, .playlists: false, .history: false, .downloads: true, .sources: true, .settings: false]
}
/// SF Symbol icon name.
@@ -42,7 +41,6 @@ enum TabBarItem: String, CaseIterable, Codable, Identifiable, Sendable {
case .downloads: "arrow.down.circle"
case .sources: "server.rack"
case .settings: "gear"
case .continueWatching: "play.circle"
}
}
@@ -57,7 +55,6 @@ enum TabBarItem: String, CaseIterable, Codable, Identifiable, Sendable {
case .downloads: String(localized: "tabBar.item.downloads")
case .sources: String(localized: "tabBar.item.sources")
case .settings: String(localized: "tabBar.item.settings")
case .continueWatching: String(localized: "tabBar.item.continueWatching")
}
}
}

View File

@@ -10,15 +10,6 @@ import Foundation
/// Closure type for loading more videos via continuation
typealias LoadMoreVideosCallback = @Sendable () async throws -> ([Video], String?)
/// Extra payload required to play files from a media source (WebDAV/SMB/local folder)
/// via `QueueManager.playFromMediaBrowser`. Without this, playback falls back to
/// `openVideo` / `playFromList`, which do not set up on-demand stream and caption
/// resolution for media-browser files.
struct MediaBrowserPlaybackInfo: Equatable, Hashable {
let source: MediaSource
let allFilesInFolder: [MediaFile]
}
/// Context information for playing a video with queue support.
/// Used when navigating from list views (subscriptions, search, etc.) to video info pages.
struct VideoQueueContext {
@@ -44,10 +35,6 @@ struct VideoQueueContext {
/// Returns new videos and updated continuation token
let loadMoreVideos: LoadMoreVideosCallback?
/// When set, playback from VideoInfoView must route through
/// `QueueManager.playFromMediaBrowser` using this payload.
var mediaBrowserPlayback: MediaBrowserPlaybackInfo? = nil
/// Whether this context has valid queue information
var hasQueueInfo: Bool {
videoList != nil && videoIndex != nil
@@ -90,8 +77,7 @@ extension VideoQueueContext: Equatable {
lhs.sourceLabel == rhs.sourceLabel &&
lhs.videoList == rhs.videoList &&
lhs.videoIndex == rhs.videoIndex &&
lhs.startTime == rhs.startTime &&
lhs.mediaBrowserPlayback == rhs.mediaBrowserPlayback
lhs.startTime == rhs.startTime
}
}
@@ -103,6 +89,5 @@ extension VideoQueueContext: Hashable {
hasher.combine(videoList)
hasher.combine(videoIndex)
hasher.combine(startTime)
hasher.combine(mediaBrowserPlayback)
}
}

View File

@@ -11,7 +11,7 @@ extension LayoutPreset {
/// Bump this version whenever any built-in preset definition changes.
/// On launch, the app compares this against the last-applied version
/// and replaces stale built-in presets with fresh copies from code.
static let builtInPresetsVersion = 8
static let builtInPresetsVersion = 5
// MARK: - Built-in Preset IDs
@@ -25,19 +25,6 @@ extension LayoutPreset {
/// Default preset with balanced controls for general use.
static func defaultPreset(for deviceClass: DeviceClass = .current) -> LayoutPreset {
LayoutPreset(
id: BuiltInID.defaultPreset,
name: String(localized: "controls.preset.default"),
createdAt: Date(timeIntervalSince1970: 0),
updatedAt: Date(timeIntervalSince1970: 0),
isBuiltIn: true,
deviceClass: deviceClass,
layout: deviceClass == .macOS ? macOSDefaultLayout() : standardDefaultLayout()
)
}
/// Default layout for iOS/tvOS (touch-oriented overlay controls).
private static func standardDefaultLayout() -> PlayerControlsLayout {
// Top buttons: titleAuthor (wideOnly), spacer, orientationLock, close
let topButtons: [ControlButtonConfiguration] = [
ControlButtonConfiguration(
@@ -123,7 +110,7 @@ extension LayoutPreset {
// Mini player: show video, tap for PiP
let miniPlayerSettings = MiniPlayerSettings()
return PlayerControlsLayout(
let layout = PlayerControlsLayout(
topSection: LayoutSection(buttons: topButtons),
centerSettings: centerSettings,
bottomSection: LayoutSection(buttons: bottomButtons),
@@ -133,84 +120,20 @@ extension LayoutPreset {
playerPillSettings: playerPillSettings,
miniPlayerSettings: miniPlayerSettings
)
}
/// Default layout for macOS, mirroring the QuickTime-style control bar:
/// top bar with title/author, keep-on-top pin and close; capsule row with
/// volume slider, transport and trailing actions around flexible spacers.
private static func macOSDefaultLayout() -> PlayerControlsLayout {
let topButtons: [ControlButtonConfiguration] = [
.defaultConfiguration(for: .titleAuthor),
.flexibleSpacer(),
.defaultConfiguration(for: .keepOnTop),
.defaultConfiguration(for: .close)
]
let bottomButtons: [ControlButtonConfiguration] = [
ControlButtonConfiguration(
buttonType: .volume,
settings: .slider(SliderSettings(sliderBehavior: .alwaysVisible))
),
.flexibleSpacer(),
.defaultConfiguration(for: .queue),
.defaultConfiguration(for: .playPrevious),
.defaultConfiguration(for: .playPause),
.defaultConfiguration(for: .playNext),
.flexibleSpacer(),
.defaultConfiguration(for: .contextMenu),
.defaultConfiguration(for: .settings),
.defaultConfiguration(for: .pictureInPicture),
.defaultConfiguration(for: .fullscreen)
]
// On macOS center settings only drive seek amounts (keyboard arrows
// and default seek buttons).
let centerSettings = CenterSectionSettings(
showPlayPause: true,
showSeekBackward: true,
showSeekForward: true,
seekBackwardSeconds: 10,
seekForwardSeconds: 10,
leftSlider: .disabled,
rightSlider: .disabled
)
let globalSettings = GlobalLayoutSettings(
style: .plain,
buttonSize: .medium,
fontStyle: .system,
systemControlsMode: .seek,
systemControlsSeekDuration: .tenSeconds,
volumeMode: .mpv
)
return PlayerControlsLayout(
topSection: LayoutSection(buttons: topButtons),
centerSettings: centerSettings,
bottomSection: LayoutSection(buttons: bottomButtons),
globalSettings: globalSettings,
progressBarSettings: ProgressBarSettings(),
gesturesSettings: nil,
playerPillSettings: nil,
miniPlayerSettings: MiniPlayerSettings()
return LayoutPreset(
id: BuiltInID.defaultPreset,
name: String(localized: "controls.preset.default"),
createdAt: Date(timeIntervalSince1970: 0),
updatedAt: Date(timeIntervalSince1970: 0),
isBuiltIn: true,
deviceClass: deviceClass,
layout: layout
)
}
/// Minimal preset with stripped-down controls for distraction-free playback.
static func minimalPreset(for deviceClass: DeviceClass = .current) -> LayoutPreset {
LayoutPreset(
id: BuiltInID.minimalPreset,
name: String(localized: "controls.preset.minimal"),
createdAt: Date(timeIntervalSince1970: 0),
updatedAt: Date(timeIntervalSince1970: 0),
isBuiltIn: true,
deviceClass: deviceClass,
layout: deviceClass == .macOS ? macOSMinimalLayout() : standardMinimalLayout()
)
}
/// Minimal layout for iOS/tvOS.
private static func standardMinimalLayout() -> PlayerControlsLayout {
let topButtons: [ControlButtonConfiguration] = [
.flexibleSpacer(),
.defaultConfiguration(for: .close)
@@ -277,7 +200,7 @@ extension LayoutPreset {
sponsorBlockSettings: SponsorBlockSegmentSettings(showSegments: false)
)
return PlayerControlsLayout(
let layout = PlayerControlsLayout(
topSection: LayoutSection(buttons: topButtons),
centerSettings: centerSettings,
bottomSection: LayoutSection(buttons: bottomButtons),
@@ -287,56 +210,15 @@ extension LayoutPreset {
playerPillSettings: playerPillSettings,
miniPlayerSettings: miniPlayerSettings
)
}
/// Minimal layout for macOS: bare play/pause and settings in the capsule,
/// close-only top bar, no chapters or SponsorBlock markers.
private static func macOSMinimalLayout() -> PlayerControlsLayout {
let topButtons: [ControlButtonConfiguration] = [
.flexibleSpacer(),
.defaultConfiguration(for: .close)
]
let bottomButtons: [ControlButtonConfiguration] = [
.flexibleSpacer(),
.defaultConfiguration(for: .playPause),
.flexibleSpacer(),
.defaultConfiguration(for: .settings)
]
let centerSettings = CenterSectionSettings(
showPlayPause: true,
showSeekBackward: true,
showSeekForward: true,
seekBackwardSeconds: 10,
seekForwardSeconds: 10,
leftSlider: .disabled,
rightSlider: .disabled
)
let globalSettings = GlobalLayoutSettings(
style: .plain,
buttonSize: .medium,
fontStyle: .system,
systemControlsMode: .seek,
systemControlsSeekDuration: .tenSeconds,
volumeMode: .mpv
)
let progressBarSettings = ProgressBarSettings(
showChapters: false,
sponsorBlockSettings: SponsorBlockSegmentSettings(showSegments: false)
)
return PlayerControlsLayout(
topSection: LayoutSection(buttons: topButtons),
centerSettings: centerSettings,
bottomSection: LayoutSection(buttons: bottomButtons),
globalSettings: globalSettings,
progressBarSettings: progressBarSettings,
gesturesSettings: nil,
playerPillSettings: nil,
miniPlayerSettings: MiniPlayerSettings()
return LayoutPreset(
id: BuiltInID.minimalPreset,
name: String(localized: "controls.preset.minimal"),
createdAt: Date(timeIntervalSince1970: 0),
updatedAt: Date(timeIntervalSince1970: 0),
isBuiltIn: true,
deviceClass: deviceClass,
layout: layout
)
}

View File

@@ -25,12 +25,6 @@ struct CenterSectionSettings: Codable, Hashable, Sendable {
/// Number of seconds for the seek forward button.
var seekForwardSeconds: Int
/// Number of seconds for the secondary seek backward shortcut ( + seek).
var secondarySeekBackwardSeconds: Int
/// Number of seconds for the secondary seek forward shortcut ( + seek).
var secondarySeekForwardSeconds: Int
/// Type of slider to show on the left edge of the player (iOS only).
var leftSlider: SideSliderType
@@ -46,8 +40,6 @@ struct CenterSectionSettings: Codable, Hashable, Sendable {
/// - showSeekForward: Show seek forward button. Defaults to true.
/// - seekBackwardSeconds: Seconds to seek backward. Defaults to 10.
/// - seekForwardSeconds: Seconds to seek forward. Defaults to 10.
/// - secondarySeekBackwardSeconds: Seconds for the secondary () backward seek. Defaults to 30.
/// - secondarySeekForwardSeconds: Seconds for the secondary () forward seek. Defaults to 30.
/// - leftSlider: Type of slider on left edge. Defaults to disabled.
/// - rightSlider: Type of slider on right edge. Defaults to disabled.
init(
@@ -56,8 +48,6 @@ struct CenterSectionSettings: Codable, Hashable, Sendable {
showSeekForward: Bool = true,
seekBackwardSeconds: Int = 10,
seekForwardSeconds: Int = 10,
secondarySeekBackwardSeconds: Int = 30,
secondarySeekForwardSeconds: Int = 30,
leftSlider: SideSliderType = .disabled,
rightSlider: SideSliderType = .disabled
) {
@@ -66,8 +56,6 @@ struct CenterSectionSettings: Codable, Hashable, Sendable {
self.showSeekForward = showSeekForward
self.seekBackwardSeconds = max(1, seekBackwardSeconds)
self.seekForwardSeconds = max(1, seekForwardSeconds)
self.secondarySeekBackwardSeconds = max(1, secondarySeekBackwardSeconds)
self.secondarySeekForwardSeconds = max(1, secondarySeekForwardSeconds)
self.leftSlider = leftSlider
self.rightSlider = rightSlider
}
@@ -80,8 +68,6 @@ struct CenterSectionSettings: Codable, Hashable, Sendable {
case showSeekForward
case seekBackwardSeconds
case seekForwardSeconds
case secondarySeekBackwardSeconds
case secondarySeekForwardSeconds
case leftSlider
case rightSlider
}
@@ -94,8 +80,6 @@ struct CenterSectionSettings: Codable, Hashable, Sendable {
seekBackwardSeconds = try container.decode(Int.self, forKey: .seekBackwardSeconds)
seekForwardSeconds = try container.decode(Int.self, forKey: .seekForwardSeconds)
// New properties with defaults for backward compatibility
secondarySeekBackwardSeconds = try container.decodeIfPresent(Int.self, forKey: .secondarySeekBackwardSeconds) ?? 30
secondarySeekForwardSeconds = try container.decodeIfPresent(Int.self, forKey: .secondarySeekForwardSeconds) ?? 30
leftSlider = try container.decodeIfPresent(SideSliderType.self, forKey: .leftSlider) ?? .disabled
rightSlider = try container.decodeIfPresent(SideSliderType.self, forKey: .rightSlider) ?? .disabled
}

View File

@@ -39,9 +39,7 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable {
case titleAuthor
case panscan
case autoPlayNext
case audioMode
case seek
case keepOnTop
// MARK: - Version Tracking
@@ -117,12 +115,8 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable {
return String(localized: "controls.button.panscan")
case .autoPlayNext:
return String(localized: "controls.button.autoPlayNext")
case .audioMode:
return String(localized: "controls.button.audioMode")
case .seek:
return String(localized: "controls.button.seek")
case .keepOnTop:
return String(localized: "controls.button.keepOnTop")
}
}
@@ -189,12 +183,8 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable {
return "arrow.left.and.right.square"
case .autoPlayNext:
return "play.square.stack.fill"
case .audioMode:
return "music.note"
case .seek:
return "goforward.10" // Default icon, actual icon is determined by settings
case .keepOnTop:
return "pin"
}
}
@@ -234,31 +224,6 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable {
/// Button types available for top/bottom sections.
static var availableForHorizontalSections: [ControlButtonType] {
#if os(macOS)
[
.spacer,
.timeDisplay,
.titleAuthor,
.playPrevious,
.playPause,
.playNext,
.seek,
.queue,
.close,
.keepOnTop,
.volume,
.pictureInPicture,
.fullscreen,
.controlsLock,
.settings,
.playbackSpeed,
.share,
.contextMenu,
.autoPlayNext,
.audioMode,
.mpvDebug
]
#else
[
.spacer,
.timeDisplay,
@@ -287,11 +252,9 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable {
.panelToggle,
.panscan,
.autoPlayNext,
.audioMode,
.airplay,
.mpvDebug
]
#endif
}
/// Button types for center section (play/pause, seek).
@@ -324,25 +287,6 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable {
/// Button types available for the mini player (curated subset for compact UI).
static var availableForMiniPlayer: [ControlButtonType] {
#if os(macOS)
[
// Transport
.playPause,
.playPrevious,
.playNext,
.seek,
// Queue & Actions
.queue,
.close,
// Player Actions
.share,
.addToPlaylist,
.pictureInPicture,
// Utility
.playbackSpeed,
.audioMode
]
#else
[
// Transport
.playPause,
@@ -358,9 +302,7 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable {
.airplay,
.pictureInPicture,
// Utility
.playbackSpeed,
.audioMode
.playbackSpeed
]
#endif
}
}

View File

@@ -411,9 +411,6 @@ struct GlobalLayoutSettings: Codable, Hashable, Sendable {
/// How volume is controlled during playback.
var volumeMode: VolumeMode
/// Forced appearance for the macOS control bar's glass background.
var controlBarTheme: ControlsTheme
/// Theme derived from style (for backwards compatibility).
var theme: ControlsTheme { style.theme }
@@ -436,7 +433,6 @@ struct GlobalLayoutSettings: Codable, Hashable, Sendable {
case systemControlsMode
case systemControlsSeekDuration
case volumeMode
case controlBarTheme
}
init(from decoder: Decoder) throws {
@@ -449,7 +445,6 @@ struct GlobalLayoutSettings: Codable, Hashable, Sendable {
systemControlsMode = try container.decodeIfPresent(SystemControlsMode.self, forKey: .systemControlsMode) ?? .seek
systemControlsSeekDuration = try container.decodeIfPresent(SystemControlsSeekDuration.self, forKey: .systemControlsSeekDuration) ?? .tenSeconds
volumeMode = try container.decodeIfPresent(VolumeMode.self, forKey: .volumeMode) ?? .mpv
controlBarTheme = try container.decodeIfPresent(ControlsTheme.self, forKey: .controlBarTheme) ?? .system
}
func encode(to encoder: Encoder) throws {
@@ -462,7 +457,6 @@ struct GlobalLayoutSettings: Codable, Hashable, Sendable {
try container.encode(systemControlsMode, forKey: .systemControlsMode)
try container.encode(systemControlsSeekDuration, forKey: .systemControlsSeekDuration)
try container.encode(volumeMode, forKey: .volumeMode)
try container.encode(controlBarTheme, forKey: .controlBarTheme)
}
// MARK: - Initialization
@@ -475,8 +469,7 @@ struct GlobalLayoutSettings: Codable, Hashable, Sendable {
controlsFadeOpacity: Double = 0.5,
systemControlsMode: SystemControlsMode = .seek,
systemControlsSeekDuration: SystemControlsSeekDuration = .tenSeconds,
volumeMode: VolumeMode = .mpv,
controlBarTheme: ControlsTheme = .system
volumeMode: VolumeMode = .mpv
) {
self.style = style
self.buttonSize = buttonSize
@@ -485,7 +478,6 @@ struct GlobalLayoutSettings: Codable, Hashable, Sendable {
self.systemControlsMode = systemControlsMode
self.systemControlsSeekDuration = systemControlsSeekDuration
self.volumeMode = volumeMode
self.controlBarTheme = controlBarTheme
}
// MARK: - Defaults

View File

@@ -99,12 +99,7 @@ struct Storyboard: Hashable, Sendable, Codable {
/// - Returns: Direct URL for the sprite sheet, or nil if invalid
func directSheetURL(for index: Int) -> URL? {
guard index >= 0, index < storyboardCount else { return nil }
// YouTube storyboard filenames are `M{N}.jpg` and the templateUrl encodes the
// slot as `M$M`. The leading `M` is literal, so replace `M$M` with `M{index}`
// (not bare `\(index)`) otherwise the file becomes `0.jpg` instead of `M0.jpg`
// and YouTube returns 404. Matching the full `M$M` token also avoids accidentally
// rewriting any `$M` that appears later in query params such as `sigh=rs$...`.
let urlString = templateUrl.replacingOccurrences(of: "M$M", with: "M\(index)")
let urlString = templateUrl.replacingOccurrences(of: "M$M", with: "\(index)")
return URL(string: urlString)
}

View File

@@ -189,64 +189,6 @@ struct StreamResolution: Codable, Hashable, Sendable, Comparable, CustomStringCo
}
}
// MARK: - Audio-Only Variant
extension Stream {
/// Whether the backend must disable the video track when loading this stream:
/// a muxed file being played as audio-only (see `audioOnlyVariant()`).
var requiresVideoTrackDisabled: Bool {
isAudioOnly && videoCodec != nil
}
/// Creates an audio-only copy of this stream keeping the same URL.
/// Used for muxed local files in audio mode: `videoCodec` stays set so
/// `requiresVideoTrackDisabled` tells the backend to skip the video track.
func audioOnlyVariant() -> Stream {
Stream(
url: url,
resolution: nil,
format: format,
videoCodec: videoCodec,
audioCodec: audioCodec,
bitrate: bitrate,
fileSize: fileSize,
isAudioOnly: true,
isLive: isLive,
mimeType: mimeType,
audioLanguage: audioLanguage,
audioTrackName: audioTrackName,
isOriginalAudio: isOriginalAudio,
httpHeaders: httpHeaders
)
}
}
// MARK: - URL Rewriting
extension Stream {
/// Creates a copy of this stream with a different URL.
/// Used for proxying streams through an instance.
func withURL(_ newURL: URL) -> Stream {
Stream(
url: newURL,
resolution: resolution,
format: format,
videoCodec: videoCodec,
audioCodec: audioCodec,
bitrate: bitrate,
fileSize: fileSize,
isAudioOnly: isAudioOnly,
isLive: isLive,
mimeType: mimeType,
audioLanguage: audioLanguage,
audioTrackName: audioTrackName,
isOriginalAudio: isOriginalAudio,
httpHeaders: httpHeaders,
fps: fps
)
}
}
// MARK: - Preview Data
extension Stream {

View File

@@ -1,29 +0,0 @@
//
// TVVideoSyncMode.swift
// Yattee
//
// MPV `video-sync` mode override exposed on tvOS for A/V sync debugging.
//
import SwiftUI
enum TVVideoSyncMode: String, CaseIterable, Codable {
/// Lock video to display refresh, drop/repeat frames to match. Audio plays at PTS.
/// Current shipping default.
case displayVdrop = "display-vdrop"
/// Lock video to display refresh; resample audio to match. Eliminates A/V drift
/// when the display mode doesn't match content fps, at the cost of tiny audio
/// pitch correction.
case displayResample = "display-resample"
/// libmpv default: video adjusts to audio. Most forgiving when display-mode
/// matching is uncertain.
case audio
var displayName: LocalizedStringKey {
switch self {
case .displayVdrop: "settings.playback.tvVideoSyncMode.displayVdrop"
case .displayResample: "settings.playback.tvVideoSyncMode.displayResample"
case .audio: "settings.playback.tvVideoSyncMode.audio"
}
}
}

View File

@@ -91,25 +91,6 @@ struct Video: Identifiable, Codable, Sendable {
thumbnails.sorted { $0.quality > $1.quality }.first
}
/// Thumbnail URLs ordered best-quality-first.
///
/// Used as a fallback chain: backends (and YouTube's CDN) often advertise
/// `maxres`/`sddefault` variants that don't actually exist for a given video
/// and 404. Consumers try these in order and drop to the next when one fails.
var thumbnailURLsByQuality: [URL] {
thumbnails.sorted { $0.quality > $1.quality }.map(\.url)
}
/// Best thumbnail URL rewritten to the always-available `hqdefault` variant.
///
/// For consumers that render or fetch a single URL with no way to fall back
/// through the quality chain (Now Playing artwork, Top Shelf, remote control,
/// navigation covers): the best advertised variant is often `maxresdefault`,
/// which 404s for many older videos.
var reliableThumbnailURL: URL? {
Thumbnail.reliableURL(for: bestThumbnail?.url)
}
var formattedDuration: String {
guard !isLive else { return "LIVE" }
guard duration > 0 else { return "" }
@@ -321,55 +302,6 @@ struct Thumbnail: Codable, Hashable, Sendable {
self.width = width
self.height = height
}
/// Expands a single stored YouTube-style thumbnail URL (`/vi/<id>/<variant>.jpg`)
/// into a best-first fallback chain. Persistence models (playlists, watch history,
/// bookmarks) store only the best advertised URL usually `maxresdefault.jpg`,
/// which doesn't exist for many older videos and 404s. Reconstructing the
/// lower-quality variants lets thumbnail views fall back the same way they do
/// for live API results. Non-matching URLs get a single-entry chain.
/// Rewrites a YouTube-style thumbnail URL (`/vi/<id>/<variant>.jpg`) to the
/// `hqdefault.jpg` variant, which exists for effectively every video (unlike
/// `maxresdefault`/`sddefault`, which 404 for many older uploads). Use where a
/// single URL is displayed without fallback, e.g. playlist covers. Non-matching
/// URLs are returned unchanged.
static func reliableURL(for url: URL?) -> URL? {
guard let url else { return nil }
let variants = ["maxresdefault.jpg", "sddefault.jpg", "hqdefault.jpg", "mqdefault.jpg", "default.jpg"]
let path = url.path
guard path.range(of: #"/vi/[^/]+/"#, options: .regularExpression) != nil,
let match = variants.first(where: { path.hasSuffix($0) }),
var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return url
}
components.path = String(path.dropLast(match.count)) + "hqdefault.jpg"
return components.url ?? url
}
static func fallbackChain(for url: URL?) -> [Thumbnail] {
guard let url else { return [] }
let variants: [(suffix: String, quality: Quality)] = [
("maxresdefault.jpg", .maxres),
("sddefault.jpg", .standard),
("hqdefault.jpg", .high),
("mqdefault.jpg", .medium),
("default.jpg", .default),
]
let path = url.path
guard path.range(of: #"/vi/[^/]+/"#, options: .regularExpression) != nil,
let current = variants.first(where: { path.hasSuffix($0.suffix) }),
let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return [Thumbnail(url: url, quality: .medium)]
}
let basePath = String(path.dropLast(current.suffix.count))
return variants.compactMap { variant in
guard variant.quality <= current.quality else { return nil }
var variantComponents = components
variantComponents.path = basePath + variant.suffix
guard let variantURL = variantComponents.url else { return nil }
return Thumbnail(url: variantURL, quality: variant.quality)
}
}
}
// MARK: - Preview Support

View File

@@ -43,7 +43,7 @@ enum GridConstants {
/// Spacing between grid items.
static let spacing: CGFloat = {
#if os(tvOS)
48
32
#else
12
#endif
@@ -55,15 +55,6 @@ enum GridConstants {
/// Maximum allowed columns (to prevent excessive density).
static let maxAllowedColumns = 6
/// Minimum allowed columns. tvOS doesn't make sense with a single column.
static let minAllowedColumns: Int = {
#if os(tvOS)
2
#else
1
#endif
}()
/// Threshold for compact card styling (columns >= this use compact mode).
static let compactThreshold = 3
}
@@ -83,14 +74,14 @@ func maxGridColumns(
// Formula: availableWidth = (columns * minCardWidth) + ((columns - 1) * spacing)
// Solving for columns: columns = (availableWidth + spacing) / (minCardWidth + spacing)
let maxColumns = Int((availableWidth + spacing) / (minCardWidth + spacing))
return max(GridConstants.minAllowedColumns, min(maxColumns, GridConstants.maxAllowedColumns))
return max(1, min(maxColumns, GridConstants.maxAllowedColumns))
}
/// Creates grid columns for a LazyVGrid with the specified count.
/// - Parameter count: Number of columns
/// - Returns: Array of flexible GridItems with top alignment
func makeGridColumns(count: Int) -> [GridItem] {
Array(repeating: GridItem(.flexible(), spacing: GridConstants.spacing, alignment: .top), count: max(GridConstants.minAllowedColumns, count))
Array(repeating: GridItem(.flexible(), spacing: GridConstants.spacing, alignment: .top), count: max(1, count))
}
// MARK: - Grid Layout Configuration
@@ -127,7 +118,7 @@ struct GridLayoutConfiguration {
/// Effective column count, clamped to valid range.
var effectiveColumns: Int {
min(max(GridConstants.minAllowedColumns, gridColumns), max(GridConstants.minAllowedColumns, maxColumns))
min(max(1, gridColumns), max(1, maxColumns))
}
/// Whether cards should use compact styling (3+ columns).

View File

@@ -41,10 +41,10 @@ actor ContentService: ContentServiceProtocol {
private let defaultPeerTubeAPI: PeerTubeAPI
private let defaultYatteeServerAPI: YatteeServerAPI
/// Credentials manager for fetching basic auth headers on demand.
private let basicAuthCredentialsManager: BasicAuthCredentialsManager?
/// Credentials manager for fetching Yattee Server auth headers on demand.
private let yatteeServerCredentialsManager: YatteeServerCredentialsManager?
init(httpClient: HTTPClient, basicAuthCredentialsManager: BasicAuthCredentialsManager? = nil) {
init(httpClient: HTTPClient, yatteeServerCredentialsManager: YatteeServerCredentialsManager? = nil) {
// Legacy init - create factory internally
self.httpClientFactory = HTTPClientFactory()
self.defaultHTTPClient = httpClient
@@ -52,10 +52,10 @@ actor ContentService: ContentServiceProtocol {
self.defaultPipedAPI = PipedAPI(httpClient: httpClient)
self.defaultPeerTubeAPI = PeerTubeAPI(httpClient: httpClient)
self.defaultYatteeServerAPI = YatteeServerAPI(httpClient: httpClient)
self.basicAuthCredentialsManager = basicAuthCredentialsManager
self.yatteeServerCredentialsManager = yatteeServerCredentialsManager
}
init(httpClientFactory: HTTPClientFactory, basicAuthCredentialsManager: BasicAuthCredentialsManager? = nil) {
init(httpClientFactory: HTTPClientFactory, yatteeServerCredentialsManager: YatteeServerCredentialsManager? = nil) {
self.httpClientFactory = httpClientFactory
// Create default client for instances that don't need insecure SSL
self.defaultHTTPClient = httpClientFactory.createClient(allowInvalidCertificates: false)
@@ -63,54 +63,65 @@ actor ContentService: ContentServiceProtocol {
self.defaultPipedAPI = PipedAPI(httpClient: defaultHTTPClient)
self.defaultPeerTubeAPI = PeerTubeAPI(httpClient: defaultHTTPClient)
self.defaultYatteeServerAPI = YatteeServerAPI(httpClient: defaultHTTPClient)
self.basicAuthCredentialsManager = basicAuthCredentialsManager
self.yatteeServerCredentialsManager = yatteeServerCredentialsManager
}
// MARK: - Routing
/// Builds a per-instance HTTPClient with the basic-auth `Authorization` header baked in,
/// or returns nil if no basic-auth credentials are configured for the instance.
/// Used to inject reverse-proxy basic auth uniformly across all backends.
private func httpClientWithBasicAuth(for instance: Instance) async -> HTTPClient? {
guard let authHeader = await basicAuthCredentialsManager?.basicAuthHeader(for: instance) else {
return nil
}
let client = httpClientFactory.createClient(for: instance)
await client.setDefaultHeaders(["Authorization": authHeader])
return client
}
/// Returns an API client configured for the instance's SSL and auth requirements.
private func api(for instance: Instance) async -> any InstanceAPI {
// For Yattee Server, use the dedicated method that handles auth
if instance.type == .yatteeServer {
return await yatteeServerAPI(for: instance)
}
// For instances with standard SSL, use cached default API clients
if !instance.allowInvalidCertificates {
switch instance.type {
case .invidious:
return await invidiousAPI(for: instance)
return defaultInvidiousAPI
case .piped:
return await pipedAPI(for: instance)
return defaultPipedAPI
case .peertube:
return await peerTubeAPI(for: instance)
return defaultPeerTubeAPI
case .yatteeServer:
return await yatteeServerAPI(for: instance)
fatalError("Should be handled above")
}
}
// For instances with allowInvalidCertificates, create API with insecure HTTPClient
let insecureClient = httpClientFactory.createClient(for: instance)
switch instance.type {
case .invidious:
return InvidiousAPI(httpClient: insecureClient)
case .piped:
return PipedAPI(httpClient: insecureClient)
case .peertube:
return PeerTubeAPI(httpClient: insecureClient)
case .yatteeServer:
fatalError("Should be handled above")
}
}
/// Returns a YatteeServerAPI configured for the instance's SSL and auth requirements.
private func yatteeServerAPI(for instance: Instance) async -> YatteeServerAPI {
if let authClient = await httpClientWithBasicAuth(for: instance) {
return YatteeServerAPI(httpClient: authClient)
}
let api: YatteeServerAPI
if !instance.allowInvalidCertificates {
return defaultYatteeServerAPI
}
api = defaultYatteeServerAPI
} else {
let insecureClient = httpClientFactory.createClient(for: instance)
return YatteeServerAPI(httpClient: insecureClient)
api = YatteeServerAPI(httpClient: insecureClient)
}
/// Returns an InvidiousAPI configured for the instance's SSL and auth requirements.
private func invidiousAPI(for instance: Instance) async -> InvidiousAPI {
if let authClient = await httpClientWithBasicAuth(for: instance) {
return InvidiousAPI(httpClient: authClient)
// Fetch auth header directly from credentials manager (avoids race condition on app startup)
let authHeader = await yatteeServerCredentialsManager?.basicAuthHeader(for: instance)
await api.setAuthHeader(authHeader)
return api
}
/// Returns an InvidiousAPI configured for the instance's SSL requirements.
private func invidiousAPI(for instance: Instance) -> InvidiousAPI {
if !instance.allowInvalidCertificates {
return defaultInvidiousAPI
}
@@ -118,11 +129,8 @@ actor ContentService: ContentServiceProtocol {
return InvidiousAPI(httpClient: insecureClient)
}
/// Returns a PipedAPI configured for the instance's SSL and auth requirements.
private func pipedAPI(for instance: Instance) async -> PipedAPI {
if let authClient = await httpClientWithBasicAuth(for: instance) {
return PipedAPI(httpClient: authClient)
}
/// Returns a PipedAPI configured for the instance's SSL requirements.
private func pipedAPI(for instance: Instance) -> PipedAPI {
if !instance.allowInvalidCertificates {
return defaultPipedAPI
}
@@ -130,11 +138,8 @@ actor ContentService: ContentServiceProtocol {
return PipedAPI(httpClient: insecureClient)
}
/// Returns a PeerTubeAPI configured for the instance's SSL and auth requirements.
private func peerTubeAPI(for instance: Instance) async -> PeerTubeAPI {
if let authClient = await httpClientWithBasicAuth(for: instance) {
return PeerTubeAPI(httpClient: authClient)
}
/// Returns a PeerTubeAPI configured for the instance's SSL requirements.
private func peerTubeAPI(for instance: Instance) -> PeerTubeAPI {
if !instance.allowInvalidCertificates {
return defaultPeerTubeAPI
}
@@ -243,27 +248,22 @@ actor ContentService: ContentServiceProtocol {
try await api(for: instance).channelSearch(id: id, query: query, instance: instance, page: page)
}
/// Fetches streams with proxy URLs for downloads (Yattee Server: hits /proxy/fast/
/// so the server caches the file on disk; other backends apply Invidious URL rewriting).
/// Fetches streams with proxy URLs for faster LAN downloads (Yattee Server only).
/// For other backends, returns regular streams.
func proxyStreams(videoID: String, instance: Instance) async throws -> [Stream] {
if instance.type == .yatteeServer {
return try await yatteeServerAPI(for: instance)
.proxyStreams(videoID: videoID, instance: instance, mode: .download)
return try await yatteeServerAPI(for: instance).proxyStreams(videoID: videoID, instance: instance)
}
let fetchedStreams = try await streams(videoID: videoID, instance: instance)
return await InvidiousAPI.proxyStreamsIfNeeded(fetchedStreams, instance: instance)
return try await streams(videoID: videoID, instance: instance)
}
/// Fetches video details + streams for downloads. For Yattee Server this routes
/// through `/proxy/fast/`, which caches the file on disk on the server.
/// Fetches video details, proxy streams, captions, and storyboards (Yattee Server only).
/// For other backends, falls back to regular streams.
func videoWithProxyStreamsAndCaptionsAndStoryboards(id: String, instance: Instance) async throws -> (video: Video, streams: [Stream], captions: [Caption], storyboards: [Storyboard]) {
if instance.type == .yatteeServer {
return try await yatteeServerAPI(for: instance)
.videoWithProxyStreamsAndCaptionsAndStoryboards(id: id, instance: instance, mode: .download)
return try await yatteeServerAPI(for: instance).videoWithProxyStreamsAndCaptionsAndStoryboards(id: id, instance: instance)
}
var result = try await videoWithStreamsAndCaptionsAndStoryboards(id: id, instance: instance)
result.streams = await InvidiousAPI.proxyStreamsIfNeeded(result.streams, instance: instance)
return result
return try await videoWithStreamsAndCaptionsAndStoryboards(id: id, instance: instance)
}
/// Fetches video details, streams, and captions in a single API call (Invidious and Yattee Server).
@@ -280,19 +280,10 @@ actor ContentService: ContentServiceProtocol {
case .invidious:
return try await invidiousAPI(for: instance).videoWithStreamsAndCaptionsAndStoryboards(id: id, instance: instance)
case .yatteeServer:
// When the user has opted into proxying for this Yattee Server source,
// fetch playback streams through the server's /proxy/relay byte-relay
// (Range-friendly, fast TTFB). When off, leave routing to the server's
// per-site proxy_streaming flag the server can still proxy by default,
// but the client doesn't force it on top.
if instance.proxiesVideos {
return try await yatteeServerAPI(for: instance)
.videoWithProxyStreamsAndCaptionsAndStoryboards(id: id, instance: instance, mode: .relay)
}
return try await yatteeServerAPI(for: instance).videoWithStreamsAndCaptionsAndStoryboards(id: id, instance: instance)
case .piped:
// Piped fallback - make separate calls (no storyboard support)
let pipedAPI = await pipedAPI(for: instance)
let pipedAPI = pipedAPI(for: instance)
async let videoTask = pipedAPI.video(id: id, instance: instance)
async let streamsTask = pipedAPI.streams(videoID: id, instance: instance)
async let captionsTask = pipedAPI.captions(videoID: id, instance: instance)
@@ -304,7 +295,7 @@ actor ContentService: ContentServiceProtocol {
case .peertube:
// PeerTube fallback - make separate calls (no storyboard support)
let peerTubeAPI = await peerTubeAPI(for: instance)
let peerTubeAPI = peerTubeAPI(for: instance)
async let videoTask = peerTubeAPI.video(id: id, instance: instance)
async let streamsTask = peerTubeAPI.streams(videoID: id, instance: instance)
async let captionsTask = peerTubeAPI.captions(videoID: id, instance: instance)

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