Compare commits

..

1 Commits

Author SHA1 Message Date
Arkadiusz Fal
05f921d605 Yattee v2 rewrite 2026-02-08 18:33:56 +01:00
411 changed files with 9598 additions and 34763 deletions

View File

@@ -0,0 +1,59 @@
name: Build and notarize macOS app (macOS 15, Xcode 16.4)
on:
workflow_dispatch:
env:
APP_NAME: Yattee
FASTLANE_USER: ${{ secrets.FASTLANE_USER }}
FASTLANE_PASSWORD: ${{ secrets.FASTLANE_PASSWORD }}
ITC_TEAM_ID: ${{ secrets.ITC_TEAM_ID }}
TEAM_ID: ${{ secrets.TEAM_ID }}
DEVELOPER_KEY_ID: ${{ secrets.DEVELOPER_KEY_ID }}
DEVELOPER_KEY_ISSUER_ID: ${{ secrets.DEVELOPER_KEY_ISSUER_ID }}
DEVELOPER_KEY_CONTENT: ${{ secrets.DEVELOPER_KEY_CONTENT }}
TEMP_KEYCHAIN_USER: ${{ secrets.TEMP_KEYCHAIN_USER }}
TEMP_KEYCHAIN_PASSWORD: ${{ secrets.TEMP_KEYCHAIN_PASSWORD }}
DEVELOPER_APP_IDENTIFIER: ${{ secrets.DEVELOPER_APP_IDENTIFIER }}
GIT_AUTHORIZATION: ${{ secrets.GIT_AUTHORIZATION }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
CERTIFICATES_GIT_URL: ${{ secrets.CERTIFICATES_GIT_URL }}
jobs:
mac_notarized:
name: Build and notarize macOS app (macOS 15, Xcode 16.4)
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.1'
bundler-cache: true
cache-version: 1
- name: Replace signing certificate to Direct with Developer ID
run: |
sed -i '' 's/match AppStore/match Direct/' Yattee.xcodeproj/project.pbxproj
sed -i '' 's/3rd Party Mac Developer Application/Developer ID Application/' Yattee.xcodeproj/project.pbxproj
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '16.4'
- name: Clear SPM cache
run: |
rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
rm -rf ~/Library/Developer/Xcode/DerivedData
rm -rf .build
- uses: maierj/fastlane-action@v3.0.0
with:
lane: mac build_and_notarize
- run: |
echo "BUILD_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 CURRENT_PROJECT_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
echo "VERSION_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 MARKETING_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
- run: |
echo "APP_PATH=fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS/Yattee.app" >> $GITHUB_ENV
echo "ZIP_PATH=fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS/Yattee-${{ env.VERSION_NUMBER }}-macOS.zip" >> $GITHUB_ENV
- name: ZIP build
run: /usr/bin/ditto -c -k --keepParent ${{ env.APP_PATH }} ${{ env.ZIP_PATH }}
- uses: actions/upload-artifact@v4
with:
name: mac-notarized-build
path: ${{ env.ZIP_PATH }}
if-no-files-found: error

35
.github/workflows/bump-build.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: Bump build number
on:
workflow_dispatch:
env:
APP_NAME: Yattee
jobs:
bump_build:
name: Bump build number
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Configure git
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.1'
bundler-cache: true
cache-version: 1
- uses: maierj/fastlane-action@v3.0.0
with:
lane: bump_build
- run: echo "BUILD_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 CURRENT_PROJECT_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
- name: Create Pull Request
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.GIT_AUTHORIZATION }}
branch: actions/bump-build-to-${{ env.BUILD_NUMBER }}
base: main
title: Bump build number to ${{ env.BUILD_NUMBER }}

View File

@@ -1,38 +1,6 @@
name: Build and release to TestFlight and GitHub
on:
workflow_dispatch:
inputs:
build_ios:
description: 'Build iOS (TestFlight)'
type: boolean
default: true
build_tvos:
description: 'Build tvOS (TestFlight)'
type: boolean
default: true
build_mac_beta:
description: 'Build macOS (TestFlight)'
type: boolean
default: true
build_mac_notarized:
description: 'Build macOS (notarized Developer ID + Sparkle appcast)'
type: boolean
default: true
release_channel:
description: 'Sparkle / Developer ID channel (also toggles GitHub prerelease flag)'
type: choice
options:
- beta
- stable
default: beta
create_release:
description: 'Create GitHub release'
type: boolean
default: true
concurrency:
group: release
cancel-in-progress: false
env:
APP_NAME: Yattee
@@ -49,307 +17,92 @@ 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
outputs:
build_number: ${{ steps.calc.outputs.build_number }}
version_number: ${{ steps.version.outputs.version_number }}
testflight:
strategy:
matrix:
# disabled mac beta lane
# lane: ['mac beta', 'ios beta', 'tvos beta']
lane: ['ios beta', 'tvos beta']
name: Releasing ${{ matrix.lane }} version to TestFlight
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4'
ruby-version: '3.1'
bundler-cache: true
cache-version: 1
- uses: maierj/fastlane-action@v3.0.0
- name: Replace signing certificate to AppStore
run: |
sed -i '' 's/match Development/match AppStore/' Yattee.xcodeproj/project.pbxproj
sed -i '' 's/iPhone Developer/iPhone Distribution/' Yattee.xcodeproj/project.pbxproj
- uses: maxim-lobanov/setup-xcode@v1
with:
lane: latest_build_number
- name: Calculate next build number
id: calc
run: |
LATEST=$(cat latest_build_number.txt)
NEXT=$((LATEST + 1))
echo "build_number=$NEXT" >> $GITHUB_OUTPUT
- name: Read version number
id: version
run: |
VERSION=$(grep -m 1 MARKETING_VERSION Yattee.xcodeproj/project.pbxproj | cut -d' ' -f3 | sed 's/;//g')
echo "version_number=$VERSION" >> $GITHUB_OUTPUT
ios_beta:
if: ${{ inputs.build_ios }}
needs: [determine_build_number]
name: Release iOS to TestFlight
runs-on: macos-26
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4'
bundler-cache: true
cache-version: 1
- 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
xcode-version: '26.0.1'
- name: Clear SPM cache
run: rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
- uses: maierj/fastlane-action@v3.0.0
with:
lane: ios beta
lane: ${{ matrix.lane }}
- uses: actions/upload-artifact@v4
with:
name: ios-beta-build
name: ${{ matrix.lane }} build
path: fastlane/builds/**/*.ipa
if-no-files-found: ignore
tvos_beta:
if: ${{ inputs.build_tvos }}
needs: [determine_build_number]
name: Release tvOS to TestFlight
runs-on: macos-26
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4'
bundler-cache: true
cache-version: 1
- 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
- name: Clear SPM cache
run: rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
- uses: maierj/fastlane-action@v3.0.0
with:
lane: tvos beta
- uses: actions/upload-artifact@v4
with:
name: tvos-beta-build
path: fastlane/builds/**/*.ipa
if-no-files-found: ignore
mac_beta:
if: ${{ inputs.build_mac_beta }}
needs: [determine_build_number]
name: Release macOS to TestFlight
runs-on: macos-26
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4'
bundler-cache: true
cache-version: 1
- 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
- name: Clear SPM cache
run: rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
- uses: maierj/fastlane-action@v3.0.0
with:
lane: mac beta
- uses: actions/upload-artifact@v4
with:
name: mac-beta-build
path: fastlane/builds/**/*.pkg
if-no-files-found: ignore
mac_notarized:
if: ${{ inputs.build_mac_notarized }}
needs: [determine_build_number]
name: Build and notarize macOS app
runs-on: macos-26
env:
BUILD_NUMBER: ${{ needs.determine_build_number.outputs.build_number }}
VERSION_NUMBER: ${{ needs.determine_build_number.outputs.version_number }}
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4'
ruby-version: '3.1'
bundler-cache: true
cache-version: 1
- name: Set build number
- name: Replace signing certificate to Direct with Developer ID
run: |
sed -i '' 's/CURRENT_PROJECT_VERSION = [0-9]*/CURRENT_PROJECT_VERSION = ${{ env.BUILD_NUMBER }}/' Yattee.xcodeproj/project.pbxproj
sed -i '' 's/match AppStore/match Direct/' Yattee.xcodeproj/project.pbxproj
sed -i '' 's/3rd Party Mac Developer Application/Developer ID Application/' Yattee.xcodeproj/project.pbxproj
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '26.0.1'
- name: Clear SPM cache
run: rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
- uses: maierj/fastlane-action@v3.0.0
with:
lane: mac build_and_notarize
- 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 "BUILD_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 CURRENT_PROJECT_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
echo "VERSION_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 MARKETING_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
- run: |
echo "APP_PATH=fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS/Yattee.app" >> $GITHUB_ENV
echo "ZIP_PATH=fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS/Yattee-${{ env.VERSION_NUMBER }}-macOS.zip" >> $GITHUB_ENV
- name: ZIP build
run: /usr/bin/ditto -c -k --keepParent ${{ env.APP_PATH }} ${{ env.ZIP_PATH }}
- uses: actions/upload-artifact@v4
with:
name: mac-notarized-build
path: |
${{ env.ZIP_PATH }}
${{ env.DMG_PATH }}
name: mac notarized build
path: ${{ env.ZIP_PATH }}
if-no-files-found: error
release:
if: ${{ inputs.create_release && !cancelled() && !failure() }}
needs: [determine_build_number, ios_beta, tvos_beta, mac_beta, mac_notarized]
needs: ['testflight', '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 }}
- 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 }}
}
- run: echo "BUILD_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 CURRENT_PROJECT_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
- run: echo "VERSION_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 MARKETING_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
- uses: actions/download-artifact@v4
with:
path: artifacts
- 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
commit: ${{ github.ref_name }}
tag: ${{ steps.compute_tag.outputs.tag }}
prerelease: ${{ steps.compute_tag.outputs.prerelease }}
artifacts: artifacts/**/*.ipa,artifacts/**/*.zip
commit: main
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,78 +0,0 @@
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:
name: Update AltStore source
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- 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
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
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
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Update altstore-source.json
run: |
DATE=$(date -u +"%Y-%m-%dT%H:%M:%S+00:00")
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 }}" \
--argjson size "${{ env.IPA_SIZE }}" \
'.apps[0].versions = [{
version: $version,
buildVersion: $build,
date: $date,
localizedDescription: "",
downloadURL: $url,
size: $size,
minOSVersion: "18.0"
}] + [.apps[0].versions[] | select(.version != $version or .buildVersion != $build)]' \
altstore-source.json > altstore-source.tmp && mv altstore-source.tmp altstore-source.json
- name: Commit and push
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add altstore-source.json
git diff --cached --quiet && echo "No changes to commit" && exit 0
git commit -m "Update AltStore source for ${{ env.VERSION_NUMBER }} (${{ env.BUILD_NUMBER }})"
git push

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 +0,0 @@
## What's Changed
### New Features
* 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
### Bug Fixes
* 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

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.1198.0)
aws-sdk-core (3.240.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -18,19 +18,18 @@ 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.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.217.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-s3 (1.209.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.2.0)
benchmark (0.5.0)
bigdecimal (4.1.0)
bigdecimal (4.0.1)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
@@ -45,7 +44,7 @@ GEM
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.112.0)
faraday (1.10.5)
faraday (1.10.4)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
@@ -64,26 +63,25 @@ GEM
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.2.0)
faraday-multipart (1.1.1)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.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.230.0)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.197)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2.0)
benchmark (>= 0.1.0)
bundler (>= 1.17.3, < 5.0.0)
bundler (>= 1.12.0, < 3.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
@@ -98,7 +96,7 @@ GEM
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, <= 2.1.1)
google-cloud-env (>= 1.6.0, < 2.0.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
@@ -111,7 +109,6 @@ GEM
naturally (~> 2.2)
nkf (~> 0.2.0)
optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
@@ -124,42 +121,41 @@ 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-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.3)
addressable (~> 2.5, >= 2.5.1)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0)
mutex_m
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
google-apis-iamcredentials_v1 (0.26.0)
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-core (>= 0.15.0, < 2.a)
rexml
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.31.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.1.1)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.6.0)
google-cloud-storage (1.59.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.5.0)
google-cloud-storage (1.47.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (>= 0.42)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.31.0)
google-cloud-core (~> 1.6)
googleauth (~> 1.9)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.11.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.1)
googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
@@ -170,7 +166,7 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.19.3)
json (2.18.0)
jwt (2.10.2)
base64
language_server-protocol (3.17.0.5)
@@ -178,7 +174,7 @@ GEM
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.19.1)
multi_json (1.18.0)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
@@ -186,14 +182,13 @@ GEM
nkf (0.2.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
parallel (1.27.0)
parser (3.3.11.1)
parser (3.3.10.0)
ast (~> 2.4.1)
racc
plist (3.7.2)
prism (1.9.0)
public_suffix (7.0.5)
prism (1.7.0)
public_suffix (7.0.0)
racc (1.8.1)
rainbow (3.1.1)
rake (13.3.1)
@@ -202,7 +197,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 +209,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)
rspec-support (3.13.6)
rubocop (1.82.1)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -228,13 +223,13 @@ GEM
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.49.0, < 2.0)
rubocop-ast (>= 1.48.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.49.1)
rubocop-ast (1.48.0)
parser (>= 3.3.7.2)
prism (~> 1.7)
rubocop-rspec (3.9.0)
prism (~> 1.4)
rubocop-rspec (3.8.0)
lint_roller (~> 1.1)
rubocop (~> 1.81)
ruby-progressbar (1.13.0)
@@ -249,6 +244,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)

100
README.md
View File

@@ -1,26 +1,100 @@
<div align="center">
<img src="assets/yattee-logo.png" width="128" height="128" alt="Yattee logo" />
<!-- TODO: new logo asset -->
<img src="https://r.yattee.stream/icons/yattee-150.png" width="150" height="150" alt="Yattee logo">
<h1>Yattee</h1>
<p>Privacy oriented video player for iOS, tvOS and macOS<br /></p>
<p>Privacy-focused video player for iOS, macOS, and tvOS</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)
[![Matrix](https://img.shields.io/matrix/yattee:matrix.org)](https://matrix.to/#/#Yattee:matrix.org)
[![Discord](https://invidget.switchblade.xyz/pSnNKhZHEG)](https://yattee.stream/discord)
<!-- TODO: new screenshot assets -->
![Screenshot](https://r.yattee.stream/screenshots/all-platforms.png)
</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.
## 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
- 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)
**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, playlists, watch history
- Search across all configured sources
- Import/export subscriptions (JSON, CSV, OPML)
**Downloads & Sync**
- Offline video and audio downloads
- iCloud sync for bookmarks, subscriptions, and history across devices
- Handoff continuity between iPhone, iPad, Mac, and Apple TV
**Platforms**
- iOS 18+ / macOS 15+ / tvOS 18+
- Native SwiftUI on every platform
- Customizable home layout, accent colors, and player controls
## 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.
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 = 250;
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 = 250;
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 = 250;
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 = 250;
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 = 1;
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 = 1;
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)
@@ -181,10 +142,10 @@ struct ContentView: View {
/// Compact width (iPhone, iPad Stage Manager small): CompactTabView with settings-based customization
/// Regular width (iPad full, iPad larger windows): UnifiedTabView with sidebar adaptable
struct iOS18AdaptiveTabView: View {
let appEnvironment: AppEnvironment
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
let appEnvironment: AppEnvironment
// Mini player sheet state (synced with NavigationCoordinator)
@State private var showingMiniPlayerQueueSheet = false
@State private var showingMiniPlayerPlaylistSheet = false
@@ -306,10 +267,10 @@ struct iOS18AdaptiveTabView: View {
/// Uses UnifiedTabView with sidebarAdaptable for regular width, CompactTabView for compact.
@available(iOS 26.1, *)
struct iOS26AdaptiveTabView: View {
let appEnvironment: AppEnvironment
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
let appEnvironment: AppEnvironment
// Mini player sheet state (synced with NavigationCoordinator)
@State private var showingMiniPlayerQueueSheet = false
@State private var showingMiniPlayerPlaylistSheet = false

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)
}
}
@@ -146,7 +102,7 @@ extension SettingsManager {
if let cached = _miniPlayerMinimizeBehavior as? MiniPlayerMinimizeBehavior { return cached }
guard let rawValue = localDefaults.string(forKey: "miniPlayerMinimizeBehavior"),
let behavior = MiniPlayerMinimizeBehavior(rawValue: rawValue) else {
return .never // Default
return .onScrollDown // Default
}
return behavior
}

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

@@ -119,57 +119,27 @@ 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

@@ -40,17 +40,12 @@ extension DataManager {
/// Deletes a local playlist.
func deletePlaylist(_ playlist: LocalPlaylist) {
let playlistID = playlist.id
let itemIDs = playlist.sortedItems.map { $0.id }
modelContext.delete(playlist)
save()
// Queue playlist and all its items for CloudKit deletion
// Queue for CloudKit deletion
cloudKitSync?.queuePlaylistDelete(playlistID: playlistID)
for itemID in itemIDs {
cloudKitSync?.queuePlaylistItemDelete(itemID: itemID)
}
NotificationCenter.default.post(name: .playlistsDidChange, object: nil)
}

View File

@@ -300,34 +300,14 @@ extension DataManager {
let descriptor = FetchDescriptor<RecentChannel>(
predicate: #Predicate { $0.channelID == channelID }
)
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
@@ -487,62 +467,16 @@ extension DataManager {
let descriptor = FetchDescriptor<RecentPlaylist>(
predicate: #Predicate { $0.playlistID == playlistID }
)
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()
existingEntry.updateProgress(seconds: seconds, duration: duration)
} 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
newEntry.watchedSeconds = seconds
if let duration, duration > 0, newEntry.duration == 0 {
newEntry.duration = duration
}
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,24 +51,13 @@ 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)
}
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
newEntry.watchedSeconds = seconds
if let duration, duration > 0, newEntry.duration == 0 {
newEntry.duration = duration
}
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)
}
@@ -156,39 +124,19 @@ 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 }
.first?
.thumbnailURL
)
(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

@@ -1,159 +0,0 @@
//
// CachedChannelData.swift
// Yattee
//
// Cached channel data from Subscription or RecentChannel for instant display.
//
import Foundation
/// Cached channel data loaded from local SwiftData stores (Subscription or RecentChannel).
/// Used to show channel info immediately while API responses are loading.
struct CachedChannelData: Codable {
let name: String
let thumbnailURL: URL?
let bannerURL: URL?
let subscriberCount: Int?
let description: String?
/// In-memory cache of author data from video detail API responses.
@MainActor
private static var authorCache: [String: CachedChannelData] = [:]
/// Whether the disk cache has been loaded into memory.
@MainActor
private static var diskLoaded = false
/// Maximum number of cached author entries.
private static let maxCacheSize = 500
private static var cacheFileURL: URL {
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
return caches.appendingPathComponent("AuthorCache", isDirectory: true)
.appendingPathComponent("authors.json")
}
init(name: String, thumbnailURL: URL?, bannerURL: URL?, subscriberCount: Int?, description: String? = nil) {
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]
authorCache[author.id] = CachedChannelData(
name: author.name,
thumbnailURL: author.thumbnailURL ?? existing?.thumbnailURL,
bannerURL: existing?.bannerURL,
subscriberCount: author.subscriberCount ?? existing?.subscriberCount,
description: existing?.description
)
// Evict oldest entries if over limit
if authorCache.count > maxCacheSize {
let excess = authorCache.count - maxCacheSize
let keysToRemove = Array(authorCache.keys.prefix(excess))
for key in keysToRemove {
authorCache.removeValue(forKey: key)
}
}
saveToDisk()
}
// MARK: - Disk Persistence
@MainActor
private static func loadFromDiskIfNeeded() {
guard !diskLoaded else { return }
diskLoaded = true
let url = cacheFileURL
guard FileManager.default.fileExists(atPath: url.path) else { return }
do {
let data = try Data(contentsOf: url)
let decoded = try JSONDecoder().decode([String: CachedChannelData].self, from: data)
// Only fill entries not already present in memory
for (key, value) in decoded where authorCache[key] == nil {
authorCache[key] = value
}
} catch {
try? FileManager.default.removeItem(at: url)
}
}
@MainActor
private static func saveToDisk() {
let snapshot = authorCache
Task.detached(priority: .utility) {
let url = cacheFileURL
let directory = url.deletingLastPathComponent()
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
if let data = try? JSONEncoder().encode(snapshot) {
try? data.write(to: url, options: .atomic)
}
}
}
init(from subscription: Subscription) {
name = subscription.name
thumbnailURL = subscription.avatarURL
bannerURL = subscription.bannerURL
subscriberCount = subscription.subscriberCount
description = subscription.channelDescription
}
init(from recentChannel: RecentChannel) {
name = recentChannel.name
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.
@MainActor
static func load(for channelID: String, using dataManager: DataManager) -> CachedChannelData? {
loadFromDiskIfNeeded()
if let subscription = dataManager.subscription(for: channelID) {
return CachedChannelData(from: subscription)
}
if let recentChannel = dataManager.recentChannelEntry(forChannelID: channelID) {
return CachedChannelData(from: recentChannel)
}
// Finally, check in-memory cache from video detail API responses
return authorCache[channelID]
}
}
// MARK: - Author Enrichment
extension Author {
/// Returns a new Author with missing fields filled in from cached channel data.
func enriched(from cached: CachedChannelData) -> Author {
Author(
id: id,
name: name,
thumbnailURL: thumbnailURL ?? cached.thumbnailURL,
subscriberCount: subscriberCount ?? cached.subscriberCount,
instance: instance,
url: url,
hasRealChannelInfo: hasRealChannelInfo
)
}
/// Convenience: looks up cached data for this author's ID and enriches if found.
@MainActor
func enriched(using dataManager: DataManager) -> Author {
guard let cached = CachedChannelData.load(for: id, using: dataManager) else {
return self
}
return enriched(from: cached)
}
}

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

@@ -94,27 +94,6 @@ struct ChannelID: Codable, Hashable, Sendable {
}
}
extension Channel {
/// Returns a copy with `thumbnailURL` filled from cached channel data if currently nil.
@MainActor
func enrichedThumbnail(using dataManager: DataManager) -> Channel {
guard thumbnailURL == nil else { return self }
guard let cached = CachedChannelData.load(for: id.channelID, using: dataManager) else {
return self
}
return Channel(
id: id,
name: name,
description: description,
subscriberCount: subscriberCount ?? cached.subscriberCount,
videoCount: videoCount,
thumbnailURL: cached.thumbnailURL,
bannerURL: bannerURL ?? cached.bannerURL,
isVerified: isVerified
)
}
}
extension ChannelID: Identifiable {
var id: String {
switch source {

View File

@@ -50,10 +50,10 @@ enum ChannelStripSize: String, CaseIterable, Codable, Hashable, Sendable {
var displayName: String {
switch self {
case .disabled: return String(localized: "common.disabled")
case .compact: return String(localized: "channelStrip.size.compact")
case .normal: return String(localized: "channelStrip.size.normal")
case .large: return String(localized: "channelStrip.size.large")
case .disabled: return String(localized: "Disabled")
case .compact: return String(localized: "Compact")
case .normal: return String(localized: "Normal")
case .large: return String(localized: "Large")
}
}
}

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,43 +10,30 @@ 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 {
/// The video being viewed
let video: Video
/// Queue source for continuation loading
let queueSource: QueueSource?
/// Display label for the queue source (e.g., "Subscriptions", "Search Results")
let sourceLabel: String?
/// All videos in the current list
let videoList: [Video]?
/// Index of the current video in the list
let videoIndex: Int?
/// Optional start time in seconds
let startTime: TimeInterval?
/// Callback to load more videos when reaching the end of the current list
/// 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 {
@@ -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 = 3
// 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(
@@ -45,10 +32,6 @@ extension LayoutPreset {
visibilityMode: .wideOnly
),
.flexibleSpacer(),
ControlButtonConfiguration(
buttonType: .contextMenu,
visibilityMode: .wideOnly
),
.defaultConfiguration(for: .orientationLock),
.defaultConfiguration(for: .close)
]
@@ -68,6 +51,10 @@ extension LayoutPreset {
visibilityMode: .wideOnly
),
.flexibleSpacer(),
ControlButtonConfiguration(
buttonType: .contextMenu,
visibilityMode: .wideOnly
),
.defaultConfiguration(for: .settings),
.defaultConfiguration(for: .pictureInPicture),
.defaultConfiguration(for: .fullscreen)
@@ -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

@@ -72,8 +72,7 @@ struct MiniPlayerSettings: Codable, Hashable, Sendable {
/// Default buttons for the mini player: play/pause and play next.
private static let defaultButtons: [ControlButtonConfiguration] = [
ControlButtonConfiguration(buttonType: .playPause),
ControlButtonConfiguration(buttonType: .playNext),
ControlButtonConfiguration(buttonType: .close)
ControlButtonConfiguration(buttonType: .playNext)
]
/// Default mini player settings.

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