Compare commits
No commits in common. "09a3ce15a0d5c7dface651ac057bc8d9762d5c49" and "54154b032feffc012952325aabd10b5fb34c4ec6" have entirely different histories.
09a3ce15a0
...
54154b032f
|
@ -1,44 +0,0 @@
|
||||||
---
|
|
||||||
description: Build jellypig tvOS (debug or release)
|
|
||||||
---
|
|
||||||
|
|
||||||
Build jellypig tvOS for the simulator. Takes an optional configuration argument:
|
|
||||||
- `debug` (default) - Fast build with debugging symbols
|
|
||||||
- `release` - Optimized build for distribution
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
- `/build` - Build in Debug configuration (default)
|
|
||||||
- `/build debug` - Build in Debug configuration (explicit)
|
|
||||||
- `/build release` - Build in Release configuration
|
|
||||||
|
|
||||||
Steps to execute:
|
|
||||||
1. Parse the configuration argument (default to "debug" if not provided or invalid)
|
|
||||||
2. Validate the configuration is either "debug" or "release" (case-insensitive)
|
|
||||||
3. Run xcodebuild with the specified configuration:
|
|
||||||
```bash
|
|
||||||
cd /Users/ashikkizhakkepallathu/Documents/claude/jellypig/jellypig
|
|
||||||
|
|
||||||
# For debug:
|
|
||||||
xcodebuild -project jellypig.xcodeproj \
|
|
||||||
-scheme "jellypig tvOS" \
|
|
||||||
-sdk appletvsimulator \
|
|
||||||
-configuration Debug \
|
|
||||||
-derivedDataPath ./DerivedData \
|
|
||||||
clean build \
|
|
||||||
CODE_SIGNING_ALLOWED=NO
|
|
||||||
|
|
||||||
# For release:
|
|
||||||
xcodebuild -project jellypig.xcodeproj \
|
|
||||||
-scheme "jellypig tvOS" \
|
|
||||||
-sdk appletvsimulator \
|
|
||||||
-configuration Release \
|
|
||||||
-derivedDataPath ./DerivedData \
|
|
||||||
clean build \
|
|
||||||
CODE_SIGNING_ALLOWED=NO
|
|
||||||
```
|
|
||||||
4. Report build status (success or failure)
|
|
||||||
5. Display the output path of the built app
|
|
||||||
|
|
||||||
Expected output location:
|
|
||||||
- Debug: `./DerivedData/Build/Products/Debug-appletvsimulator/jellypig tvOS.app`
|
|
||||||
- Release: `./DerivedData/Build/Products/Release-appletvsimulator/jellypig tvOS.app`
|
|
|
@ -1,17 +0,0 @@
|
||||||
---
|
|
||||||
description: Initialize development session by reading project context and displaying available commands
|
|
||||||
---
|
|
||||||
|
|
||||||
Read the chats-summary.txt file from the parent directory (/Users/ashikkizhakkepallathu/Documents/claude/jellypig/chats-summary.txt) to understand the project context, then display a quick summary of what you can help with.
|
|
||||||
|
|
||||||
Steps:
|
|
||||||
1. Read /Users/ashikkizhakkepallathu/Documents/claude/jellypig/chats-summary.txt
|
|
||||||
2. Display a concise summary including:
|
|
||||||
- Project name and description
|
|
||||||
- Available custom slash commands (/build, /sim, etc.)
|
|
||||||
- Recent features implemented
|
|
||||||
- Key configuration details (Bundle ID, Simulator UUID, etc.)
|
|
||||||
- Build method: **Command-line builds work** via xcodebuild (no Xcode GUI required)
|
|
||||||
- Common tasks you can help with
|
|
||||||
|
|
||||||
Make the output brief and actionable - focus on what's immediately useful for the developer.
|
|
|
@ -1,41 +0,0 @@
|
||||||
---
|
|
||||||
description: Build and launch jellypig tvOS in simulator
|
|
||||||
---
|
|
||||||
|
|
||||||
Build the latest version of jellypig tvOS in Debug configuration, install it on the Apple TV simulator, and launch it.
|
|
||||||
|
|
||||||
Steps:
|
|
||||||
1. First, build the project using the same approach as `/build debug`:
|
|
||||||
```bash
|
|
||||||
cd /Users/ashikkizhakkepallathu/Documents/claude/jellypig/jellypig
|
|
||||||
xcodebuild -project jellypig.xcodeproj \
|
|
||||||
-scheme "jellypig tvOS" \
|
|
||||||
-sdk appletvsimulator \
|
|
||||||
-configuration Debug \
|
|
||||||
-derivedDataPath ./DerivedData \
|
|
||||||
clean build \
|
|
||||||
CODE_SIGNING_ALLOWED=NO
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Boot the Apple TV simulator:
|
|
||||||
```bash
|
|
||||||
xcrun simctl boot 16A71179-729D-4F1B-8698-8371F137025B 2>/dev/null || true
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Open Simulator.app:
|
|
||||||
```bash
|
|
||||||
open -a Simulator
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Install the built app on the simulator:
|
|
||||||
```bash
|
|
||||||
xcrun simctl install 16A71179-729D-4F1B-8698-8371F137025B \
|
|
||||||
"./DerivedData/Build/Products/Debug-appletvsimulator/jellypig tvOS.app"
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Launch the app:
|
|
||||||
```bash
|
|
||||||
xcrun simctl launch 16A71179-729D-4F1B-8698-8371F137025B org.ashik.jellypig
|
|
||||||
```
|
|
||||||
|
|
||||||
Report build and launch status. If any step fails, provide clear error message.
|
|
|
@ -1,107 +0,0 @@
|
||||||
# jellypig tvOS Alpha 1 Release
|
|
||||||
|
|
||||||
**Release Date**: October 17, 2025
|
|
||||||
**Base Version**: Swiftfin 1.3
|
|
||||||
**Branch**: jellypig-1.3
|
|
||||||
**License**: MPL-2.0
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
jellypig is a personal fork of [Jellyfin/Swiftfin](https://github.com/jellyfin/swiftfin) optimized for tvOS with specialized support for Jellyfin.Xtream plugin content. This alpha1 release represents a stable, working build that plays everything upstream Swiftfin on tvOS plays **and more**, with an elegant native UI maintained by the Swiftfin development team.
|
|
||||||
|
|
||||||
## Key Features
|
|
||||||
|
|
||||||
### ✅ What Works
|
|
||||||
- **Full Jellyfin Playback**: All standard Jellyfin content (movies, series, live TV) plays correctly
|
|
||||||
- **Xtream Content Support**: Properly displays and plays Xtream VOD and Series content from Jellyfin.Xtream plugin
|
|
||||||
- **Channel Navigation**: Fixed channel browsing with proper grid view for categories and content
|
|
||||||
- **Full-Screen Navigation**: All views display full-screen (no modal popups)
|
|
||||||
- **Video Playback Controls**: ESC key properly shows controls and allows exit with confirmation
|
|
||||||
- **Native UI**: Maintains the elegant, native tvOS interface from upstream Swiftfin
|
|
||||||
|
|
||||||
### 🎯 Major Improvements Over Upstream
|
|
||||||
1. **Xtream Content Listing**: Fixed channel folder navigation to use proper Jellyfin Channel API endpoints
|
|
||||||
- Xtream VOD and Xtream Series channels now display correct, distinct content
|
|
||||||
- Channel folders display in grid view matching library behavior
|
|
||||||
|
|
||||||
2. **tvOS Navigation Architecture**: Restructured routing to fix modal popup issues
|
|
||||||
- Channels, items, and content display full-screen using `.push` navigation
|
|
||||||
- MediaCoordinator uses `.fullScreen` to prevent navigation stack corruption
|
|
||||||
- Video player properly stops on dismiss (no background playback)
|
|
||||||
|
|
||||||
3. **Leaner Codebase**: Removed entire iOS build and code (28,370+ deletions)
|
|
||||||
- tvOS-only focus reduces complexity
|
|
||||||
- Faster build times and easier maintenance
|
|
||||||
- Single target: `jellypig tvOS`
|
|
||||||
|
|
||||||
## Technical Changes
|
|
||||||
|
|
||||||
### Core Fixes
|
|
||||||
- **ItemLibraryViewModel.swift**: Implements proper channel API routing (`/Channels/{channelID}/Items`)
|
|
||||||
- **PagingLibraryView.swift**: Added `.channelFolderItem` support for grid view display
|
|
||||||
- **MainCoordinator.swift**: Restructured MediaCoordinator routing with `.fullScreen` navigation
|
|
||||||
- **ItemView.swift**: Removed `.channelFolderItem` handling (delegates to library view)
|
|
||||||
|
|
||||||
### Project Configuration
|
|
||||||
- **Bundle ID**: `org.ashik.jellypig`
|
|
||||||
- **Project Name**: jellypig (renamed from Swiftfin)
|
|
||||||
- **Targets**: tvOS only (iOS removed)
|
|
||||||
- **CI/CD**: Updated GitHub Actions workflows for jellypig tvOS builds
|
|
||||||
- **Dependencies**: Properly configured VLCKit (TVVLCKit.xcframework v3.5.0)
|
|
||||||
|
|
||||||
## Known Limitations
|
|
||||||
|
|
||||||
### Acceptable Trade-offs
|
|
||||||
- **Error Dismissal**: Errors in deeply nested views (e.g., failed episode playback) dismiss all the way to Media tab instead of one level back
|
|
||||||
- This is a limitation of `.fullScreen` presentation architecture
|
|
||||||
- Prevents worse issue of returning to Home screen
|
|
||||||
|
|
||||||
- **Video Player Back Button**: Slightly "buggy" behavior but functional
|
|
||||||
- ESC key works properly (shows controls → confirm → exit)
|
|
||||||
- Playback stops correctly on dismiss
|
|
||||||
|
|
||||||
## Development Environment
|
|
||||||
|
|
||||||
### Requirements
|
|
||||||
- **Platform**: macOS with Xcode 16+
|
|
||||||
- **Build Method**: Must use Xcode GUI (Command+B)
|
|
||||||
- Command-line `xcodebuild` fails due to Swift macro issues in swift-case-paths dependency
|
|
||||||
- All other operations (git, editing) work via command line
|
|
||||||
- **Simulator**: Any tvOS 16.0+ simulator or device
|
|
||||||
|
|
||||||
### Repository
|
|
||||||
- **Fork**: [ashikslab/jellypig](https://github.com/ashikslab/jellypig)
|
|
||||||
- **Upstream**: [jellyfin/swiftfin](https://github.com/jellyfin/swiftfin)
|
|
||||||
- **Plugin**: [ashikslab/Jellyfin.Xtream](https://github.com/ashikslab/Jellyfin.Xtream)
|
|
||||||
|
|
||||||
## Companion Plugin
|
|
||||||
|
|
||||||
jellypig works best with the reverted **Jellyfin.Xtream** plugin (v0.7.2.0001) which uses pure upstream logic:
|
|
||||||
- Repository: https://ashikslab.github.io/Jellyfin.Xtream/repository.json
|
|
||||||
- Download: https://github.com/ashikslab/Jellyfin.Xtream/releases/download/v0.7.2.0001/jellyfin-xtream-for-jellypig_0.7.2.1.zip
|
|
||||||
|
|
||||||
The plugin provides standard Jellyfin Channel API endpoints for Xtream content, and jellypig handles the display and navigation correctly.
|
|
||||||
|
|
||||||
## What's Next
|
|
||||||
|
|
||||||
### Short Term (Until Upstream Stabilizes)
|
|
||||||
- Bug fixes as discovered
|
|
||||||
- UI adjustments and polish
|
|
||||||
- Stay on Swiftfin 1.3 base
|
|
||||||
|
|
||||||
### Long Term
|
|
||||||
- **Major Intake**: When upstream Swiftfin releases their next stable major version, jellypig will merge those changes
|
|
||||||
- **iOS Support**: May consider re-adding iOS support with Xtream fixes as a separate project
|
|
||||||
- **Upstream Contributions**: Channel navigation fixes could potentially be contributed back to Swiftfin
|
|
||||||
|
|
||||||
## Credits
|
|
||||||
|
|
||||||
- **Original Project**: [Jellyfin Swiftfin](https://github.com/jellyfin/swiftfin) - MPL-2.0 License
|
|
||||||
- **Plugin**: [Kevinjil/Jellyfin.Xtream](https://github.com/Kevinjil/Jellyfin.Xtream) - GPL-3.0 License
|
|
||||||
- **jellypig Fork**: Personal modifications for Xtream content support and tvOS optimization
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**This is an alpha release**: Suitable for personal use and testing. Report issues at https://github.com/ashikslab/jellypig/issues
|
|
|
@ -1,364 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
/// A custom layout that arranges views in a flow pattern, automatically wrapping items to new rows
|
|
||||||
struct FlowLayout: Layout {
|
|
||||||
|
|
||||||
// MARK: - Fill Direction
|
|
||||||
|
|
||||||
enum Direction {
|
|
||||||
case up
|
|
||||||
case down
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Cache Structure
|
|
||||||
|
|
||||||
struct CacheData {
|
|
||||||
let subviewSizes: [CGSize]
|
|
||||||
let rows: [[Int]]
|
|
||||||
let totalSize: CGSize
|
|
||||||
let lastWidth: CGFloat?
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Properties
|
|
||||||
|
|
||||||
/// The alignment of content within each row (leading, center, or trailing)
|
|
||||||
private let alignment: HorizontalAlignment
|
|
||||||
/// Controls whether items fill from the top row down or bottom row up when wrapping
|
|
||||||
private let direction: Direction
|
|
||||||
/// The horizontal spacing between items within the same row
|
|
||||||
private let spacing: CGFloat
|
|
||||||
/// The vertical spacing between the top and bottom rows when content wraps
|
|
||||||
private let lineSpacing: CGFloat
|
|
||||||
/// The minimum number of items that must be in the smaller row when wrapping occurs
|
|
||||||
private let minRowLength: Int
|
|
||||||
|
|
||||||
init(
|
|
||||||
alignment: HorizontalAlignment = .center,
|
|
||||||
direction: Direction = .up,
|
|
||||||
spacing: CGFloat = 8,
|
|
||||||
lineSpacing: CGFloat = 8,
|
|
||||||
minRowLength: Int = 2
|
|
||||||
) {
|
|
||||||
self.alignment = alignment
|
|
||||||
self.direction = direction
|
|
||||||
self.spacing = spacing
|
|
||||||
self.lineSpacing = lineSpacing
|
|
||||||
self.minRowLength = minRowLength
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Make Cache
|
|
||||||
|
|
||||||
func makeCache(subviews: Subviews) -> CacheData {
|
|
||||||
CacheData(
|
|
||||||
subviewSizes: [],
|
|
||||||
rows: [],
|
|
||||||
totalSize: .zero,
|
|
||||||
lastWidth: nil
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Update Cache
|
|
||||||
|
|
||||||
func updateCache(_ cache: inout CacheData, subviews: Subviews) {
|
|
||||||
cache = CacheData(
|
|
||||||
subviewSizes: [],
|
|
||||||
rows: [],
|
|
||||||
totalSize: .zero,
|
|
||||||
lastWidth: nil
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Calculate Layout
|
|
||||||
|
|
||||||
private func calculateLayout(
|
|
||||||
subviews: Subviews,
|
|
||||||
width: CGFloat
|
|
||||||
) -> (sizes: [CGSize], rows: [[Int]], totalSize: CGSize) {
|
|
||||||
let sizes = subviews.map { subview in
|
|
||||||
let size = subview.sizeThatFits(.unspecified)
|
|
||||||
return CGSize(width: ceil(size.width), height: ceil(size.height))
|
|
||||||
}
|
|
||||||
|
|
||||||
let rows = computeRows(sizes: sizes, maxWidth: width)
|
|
||||||
let totalSize = computeTotalSize(rows: rows, sizes: sizes)
|
|
||||||
|
|
||||||
return (sizes, rows, totalSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Size That Fits
|
|
||||||
|
|
||||||
/// Calculates the minimum size needed to display all subviews according to the flow layout rules
|
|
||||||
func sizeThatFits(
|
|
||||||
proposal: ProposedViewSize,
|
|
||||||
subviews: Subviews,
|
|
||||||
cache: inout CacheData
|
|
||||||
) -> CGSize {
|
|
||||||
let availableWidth = proposal.width ?? .infinity
|
|
||||||
let effectiveWidth = availableWidth.isFinite ? availableWidth : 1000
|
|
||||||
|
|
||||||
if cache.lastWidth != effectiveWidth || cache.subviewSizes.isEmpty {
|
|
||||||
let (sizes, rows, totalSize) = calculateLayout(
|
|
||||||
subviews: subviews,
|
|
||||||
width: effectiveWidth
|
|
||||||
)
|
|
||||||
|
|
||||||
cache = CacheData(
|
|
||||||
subviewSizes: sizes,
|
|
||||||
rows: rows,
|
|
||||||
totalSize: totalSize,
|
|
||||||
lastWidth: effectiveWidth
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the calculated height but respect the proposed width
|
|
||||||
return CGSize(
|
|
||||||
width: min(cache.totalSize.width, proposal.width ?? cache.totalSize.width),
|
|
||||||
height: cache.totalSize.height
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Place Subviews
|
|
||||||
|
|
||||||
/// Positions each subview within the given bounds according to the flow layout rules
|
|
||||||
func placeSubviews(
|
|
||||||
in bounds: CGRect,
|
|
||||||
proposal: ProposedViewSize,
|
|
||||||
subviews: Subviews,
|
|
||||||
cache: inout CacheData
|
|
||||||
) {
|
|
||||||
let availableWidth = bounds.width
|
|
||||||
|
|
||||||
if cache.lastWidth != availableWidth || cache.subviewSizes.isEmpty {
|
|
||||||
let (sizes, rows, totalSize) = calculateLayout(
|
|
||||||
subviews: subviews,
|
|
||||||
width: availableWidth
|
|
||||||
)
|
|
||||||
|
|
||||||
cache = CacheData(
|
|
||||||
subviewSizes: sizes,
|
|
||||||
rows: rows,
|
|
||||||
totalSize: totalSize,
|
|
||||||
lastWidth: availableWidth
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let sizes = cache.subviewSizes
|
|
||||||
let rows = cache.rows
|
|
||||||
|
|
||||||
var yOffset: CGFloat = bounds.minY
|
|
||||||
|
|
||||||
for row in rows {
|
|
||||||
let rowHeight = row.map { sizes[$0].height }.max() ?? 0
|
|
||||||
let rowWidth = computeRowWidth(indices: row, sizes: sizes)
|
|
||||||
let xOffset = computeXOffset(rowWidth: rowWidth, bounds: bounds)
|
|
||||||
|
|
||||||
var x = xOffset
|
|
||||||
for index in row {
|
|
||||||
let size = sizes[index]
|
|
||||||
let y = yOffset + (rowHeight - size.height) / 2
|
|
||||||
|
|
||||||
subviews[index].place(
|
|
||||||
at: CGPoint(x: x, y: y),
|
|
||||||
anchor: .topLeading,
|
|
||||||
proposal: ProposedViewSize(size)
|
|
||||||
)
|
|
||||||
|
|
||||||
x += size.width + spacing
|
|
||||||
}
|
|
||||||
|
|
||||||
yOffset += rowHeight + lineSpacing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Compute Rows
|
|
||||||
|
|
||||||
/// Determines how to distribute items across rows based on the available width
|
|
||||||
private func computeRows(
|
|
||||||
sizes: [CGSize],
|
|
||||||
maxWidth: CGFloat
|
|
||||||
) -> [[Int]] {
|
|
||||||
guard sizes.count > 1 else {
|
|
||||||
return sizes.isEmpty ? [] : [[0]]
|
|
||||||
}
|
|
||||||
|
|
||||||
// First create rows by fitting items naturally
|
|
||||||
let rows = createInitialRows(sizes: sizes, maxWidth: maxWidth)
|
|
||||||
|
|
||||||
// Then optimize distribution based on flow direction
|
|
||||||
return optimizeRowDistribution(rows: rows, sizes: sizes, maxWidth: maxWidth)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create initial rows by fitting items sequentially
|
|
||||||
private func createInitialRows(
|
|
||||||
sizes: [CGSize],
|
|
||||||
maxWidth: CGFloat
|
|
||||||
) -> [[Int]] {
|
|
||||||
var rows: [[Int]] = []
|
|
||||||
var currentRow: [Int] = []
|
|
||||||
var currentWidth: CGFloat = 0
|
|
||||||
|
|
||||||
for (index, size) in sizes.enumerated() {
|
|
||||||
if currentRow.isEmpty {
|
|
||||||
currentRow.append(index)
|
|
||||||
currentWidth = size.width
|
|
||||||
} else {
|
|
||||||
let widthWithItem = currentWidth + spacing + size.width
|
|
||||||
|
|
||||||
if widthWithItem <= maxWidth {
|
|
||||||
currentRow.append(index)
|
|
||||||
currentWidth = widthWithItem
|
|
||||||
} else {
|
|
||||||
rows.append(currentRow)
|
|
||||||
currentRow = [index]
|
|
||||||
currentWidth = size.width
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !currentRow.isEmpty {
|
|
||||||
rows.append(currentRow)
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Optimize row distribution based on flow direction
|
|
||||||
private func optimizeRowDistribution(
|
|
||||||
rows: [[Int]],
|
|
||||||
sizes: [CGSize],
|
|
||||||
maxWidth: CGFloat
|
|
||||||
) -> [[Int]] {
|
|
||||||
guard rows.count > 1 else { return rows }
|
|
||||||
|
|
||||||
var optimizedRows = rows
|
|
||||||
|
|
||||||
switch direction {
|
|
||||||
case .up:
|
|
||||||
// Move items from earlier rows to later rows to create upward flow
|
|
||||||
optimizedRows = balanceRowsForUpwardFlow(rows: optimizedRows, sizes: sizes, maxWidth: maxWidth)
|
|
||||||
case .down:
|
|
||||||
// Move items from later rows to earlier rows to create downward flow
|
|
||||||
optimizedRows = balanceRowsForDownwardFlow(rows: optimizedRows, sizes: sizes, maxWidth: maxWidth)
|
|
||||||
}
|
|
||||||
|
|
||||||
return optimizedRows
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Balance rows for upward flow - fill bottom rows more than top rows
|
|
||||||
private func balanceRowsForUpwardFlow(
|
|
||||||
rows: [[Int]],
|
|
||||||
sizes: [CGSize],
|
|
||||||
maxWidth: CGFloat
|
|
||||||
) -> [[Int]] {
|
|
||||||
var optimizedRows = rows
|
|
||||||
|
|
||||||
for i in 0 ..< optimizedRows.count - 1 {
|
|
||||||
while optimizedRows[i].count > minRowLength {
|
|
||||||
let lastItem = optimizedRows[i].last!
|
|
||||||
|
|
||||||
var testRow = optimizedRows[i + 1]
|
|
||||||
testRow.append(lastItem)
|
|
||||||
let newWidth = computeRowWidth(indices: testRow, sizes: sizes)
|
|
||||||
|
|
||||||
if newWidth <= maxWidth {
|
|
||||||
optimizedRows[i].removeLast()
|
|
||||||
optimizedRows[i + 1].append(lastItem)
|
|
||||||
} else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return optimizedRows
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Balance rows for downward flow - fill top rows more than bottom rows
|
|
||||||
private func balanceRowsForDownwardFlow(
|
|
||||||
rows: [[Int]],
|
|
||||||
sizes: [CGSize],
|
|
||||||
maxWidth: CGFloat
|
|
||||||
) -> [[Int]] {
|
|
||||||
var optimizedRows = rows
|
|
||||||
|
|
||||||
for i in (0 ..< optimizedRows.count - 1).reversed() {
|
|
||||||
while optimizedRows[i + 1].count > minRowLength {
|
|
||||||
let firstItem = optimizedRows[i + 1].first!
|
|
||||||
|
|
||||||
var testRow = optimizedRows[i]
|
|
||||||
testRow.append(firstItem)
|
|
||||||
let newWidth = computeRowWidth(indices: testRow, sizes: sizes)
|
|
||||||
|
|
||||||
if newWidth <= maxWidth {
|
|
||||||
optimizedRows[i + 1].removeFirst()
|
|
||||||
optimizedRows[i].append(firstItem)
|
|
||||||
} else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return optimizedRows
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Compute Row Width
|
|
||||||
|
|
||||||
/// Calculates the total width needed for a row of items including spacing
|
|
||||||
private func computeRowWidth(
|
|
||||||
indices: [Int],
|
|
||||||
sizes: [CGSize]
|
|
||||||
) -> CGFloat {
|
|
||||||
guard indices.isNotEmpty else { return 0 }
|
|
||||||
|
|
||||||
let itemsWidth = indices.reduce(0) { $0 + sizes[$1].width }
|
|
||||||
let spacingWidth = spacing * CGFloat(indices.count - 1)
|
|
||||||
|
|
||||||
return itemsWidth + spacingWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Compute X Offset
|
|
||||||
|
|
||||||
/// Calculates the starting X position for a row based on the alignment setting
|
|
||||||
private func computeXOffset(
|
|
||||||
rowWidth: CGFloat,
|
|
||||||
bounds: CGRect
|
|
||||||
) -> CGFloat {
|
|
||||||
switch alignment {
|
|
||||||
case .trailing:
|
|
||||||
return bounds.maxX - rowWidth
|
|
||||||
case .center:
|
|
||||||
return bounds.minX + (bounds.width - rowWidth) / 2
|
|
||||||
default:
|
|
||||||
return bounds.minX
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Compute Total Size
|
|
||||||
|
|
||||||
/// Calculates the total size needed to display all rows with proper spacing
|
|
||||||
private func computeTotalSize(
|
|
||||||
rows: [[Int]],
|
|
||||||
sizes: [CGSize]
|
|
||||||
) -> CGSize {
|
|
||||||
guard rows.isNotEmpty else { return .zero }
|
|
||||||
|
|
||||||
let rowHeights = rows.map { row in
|
|
||||||
row.map { sizes[$0].height }.max() ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalHeight = rowHeights.reduce(0, +) + lineSpacing * CGFloat(rows.count - 1)
|
|
||||||
|
|
||||||
let maxWidth = rows.map { row in
|
|
||||||
computeRowWidth(indices: row, sizes: sizes)
|
|
||||||
}.max() ?? 0
|
|
||||||
|
|
||||||
return CGSize(width: maxWidth, height: totalHeight)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,131 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import JellyfinAPI
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct CountryPicker: View {
|
|
||||||
|
|
||||||
// MARK: - State Objects
|
|
||||||
|
|
||||||
@StateObject
|
|
||||||
private var viewModel: CountriesViewModel
|
|
||||||
|
|
||||||
// MARK: - Input Properties
|
|
||||||
|
|
||||||
private var selectionBinding: Binding<CountryInfo?>
|
|
||||||
private let title: String
|
|
||||||
|
|
||||||
@State
|
|
||||||
private var selection: CountryInfo?
|
|
||||||
|
|
||||||
// MARK: - Body
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Group {
|
|
||||||
#if os(tvOS)
|
|
||||||
ListRowMenu(title, subtitle: $selection.wrappedValue?.displayTitle) {
|
|
||||||
Picker(title, selection: $selection) {
|
|
||||||
Text(CountryInfo.none.displayTitle)
|
|
||||||
.tag(CountryInfo.none as CountryInfo?)
|
|
||||||
|
|
||||||
ForEach(viewModel.value, id: \.self) { country in
|
|
||||||
Text(country.displayTitle)
|
|
||||||
.tag(country as CountryInfo?)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// TODO: iOS 17+ move this to the Group
|
|
||||||
.onChange(of: viewModel.value) {
|
|
||||||
updateSelection()
|
|
||||||
}
|
|
||||||
.onChange(of: selection) { _, newValue in
|
|
||||||
selectionBinding.wrappedValue = newValue
|
|
||||||
}
|
|
||||||
.menuStyle(.borderlessButton)
|
|
||||||
.listRowInsets(.zero)
|
|
||||||
#else
|
|
||||||
Picker(title, selection: $selection) {
|
|
||||||
|
|
||||||
Text(CountryInfo.none.displayTitle)
|
|
||||||
.tag(CountryInfo.none as CountryInfo?)
|
|
||||||
|
|
||||||
ForEach(viewModel.value, id: \.self) { country in
|
|
||||||
Text(country.displayTitle)
|
|
||||||
.tag(country as CountryInfo?)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// TODO: iOS 17+ delete this and use the tvOS onChange at the Group level
|
|
||||||
.onChange(of: viewModel.value) { _ in
|
|
||||||
updateSelection()
|
|
||||||
}
|
|
||||||
.onChange(of: selection) { newValue in
|
|
||||||
selectionBinding.wrappedValue = newValue
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
.onFirstAppear {
|
|
||||||
viewModel.refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateSelection() {
|
|
||||||
let newValue = viewModel.value.first { value in
|
|
||||||
if let selectedTwo = selection?.twoLetterISORegionName,
|
|
||||||
let candidateTwo = value.twoLetterISORegionName,
|
|
||||||
selectedTwo == candidateTwo
|
|
||||||
{
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if let selectedThree = selection?.threeLetterISORegionName,
|
|
||||||
let candidateThree = value.threeLetterISORegionName,
|
|
||||||
selectedThree == candidateThree
|
|
||||||
{
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
selection = newValue ?? CountryInfo.none
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension CountryPicker {
|
|
||||||
|
|
||||||
init(_ title: String, twoLetterISORegion: Binding<String?>) {
|
|
||||||
self.title = title
|
|
||||||
self._selection = State(
|
|
||||||
initialValue: twoLetterISORegion.wrappedValue.flatMap { code in
|
|
||||||
CountryInfo(
|
|
||||||
name: code,
|
|
||||||
twoLetterISORegionName: code
|
|
||||||
)
|
|
||||||
} ?? CountryInfo.none
|
|
||||||
)
|
|
||||||
self.selectionBinding = Binding(
|
|
||||||
get: {
|
|
||||||
guard let code = twoLetterISORegion.wrappedValue else {
|
|
||||||
return CountryInfo.none
|
|
||||||
}
|
|
||||||
return CountryInfo(
|
|
||||||
name: code,
|
|
||||||
twoLetterISORegionName: code
|
|
||||||
)
|
|
||||||
},
|
|
||||||
set: { newCountry in
|
|
||||||
twoLetterISORegion.wrappedValue = newCountry?.twoLetterISORegionName
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
self._viewModel = StateObject(
|
|
||||||
wrappedValue: CountriesViewModel(
|
|
||||||
initialValue: [.none]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,153 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import JellyfinAPI
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct CulturePicker: View {
|
|
||||||
|
|
||||||
// MARK: - State Objects
|
|
||||||
|
|
||||||
@StateObject
|
|
||||||
private var viewModel: CulturesViewModel
|
|
||||||
|
|
||||||
// MARK: - Input Properties
|
|
||||||
|
|
||||||
private var selectionBinding: Binding<CultureDto?>
|
|
||||||
private let title: String
|
|
||||||
|
|
||||||
@State
|
|
||||||
private var selection: CultureDto?
|
|
||||||
|
|
||||||
// MARK: - Body
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Group {
|
|
||||||
#if os(tvOS)
|
|
||||||
ListRowMenu(title, subtitle: $selection.wrappedValue?.displayTitle) {
|
|
||||||
Picker(title, selection: $selection) {
|
|
||||||
Text(CultureDto.none.displayTitle)
|
|
||||||
.tag(CultureDto.none as CultureDto?)
|
|
||||||
|
|
||||||
ForEach(viewModel.value, id: \.self) { value in
|
|
||||||
Text(value.displayTitle)
|
|
||||||
.tag(value as CultureDto?)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// TODO: iOS 17+ move this to the Group
|
|
||||||
.onChange(of: viewModel.value) {
|
|
||||||
updateSelection()
|
|
||||||
}
|
|
||||||
.onChange(of: selection) { _, newValue in
|
|
||||||
selectionBinding.wrappedValue = newValue
|
|
||||||
}
|
|
||||||
.menuStyle(.borderlessButton)
|
|
||||||
.listRowInsets(.zero)
|
|
||||||
#else
|
|
||||||
Picker(title, selection: $selection) {
|
|
||||||
|
|
||||||
Text(CultureDto.none.displayTitle)
|
|
||||||
.tag(CultureDto.none as CultureDto?)
|
|
||||||
|
|
||||||
ForEach(viewModel.value, id: \.self) { value in
|
|
||||||
Text(value.displayTitle)
|
|
||||||
.tag(value as CultureDto?)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// TODO: iOS 17+ delete this and use the tvOS onChange at the Group level
|
|
||||||
.onChange(of: viewModel.value) { _ in
|
|
||||||
updateSelection()
|
|
||||||
}
|
|
||||||
.onChange(of: selection) { newValue in
|
|
||||||
selectionBinding.wrappedValue = newValue
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
.onFirstAppear {
|
|
||||||
viewModel.refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateSelection() {
|
|
||||||
let newValue = viewModel.value.first { value in
|
|
||||||
if let selectedTwo = selection?.twoLetterISOLanguageName,
|
|
||||||
let candidateTwo = value.twoLetterISOLanguageName,
|
|
||||||
selectedTwo == candidateTwo
|
|
||||||
{
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if let selectedThree = selection?.threeLetterISOLanguageName,
|
|
||||||
let candidateThree = value.threeLetterISOLanguageName,
|
|
||||||
selectedThree == candidateThree
|
|
||||||
{
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
selection = newValue ?? CultureDto.none
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension CulturePicker {
|
|
||||||
|
|
||||||
init(_ title: String, twoLetterISOLanguageName: Binding<String?>) {
|
|
||||||
self.title = title
|
|
||||||
self._selection = State(
|
|
||||||
initialValue: twoLetterISOLanguageName.wrappedValue.flatMap {
|
|
||||||
CultureDto(twoLetterISOLanguageName: $0)
|
|
||||||
} ?? CultureDto.none
|
|
||||||
)
|
|
||||||
|
|
||||||
self.selectionBinding = Binding<CultureDto?>(
|
|
||||||
get: {
|
|
||||||
guard let code = twoLetterISOLanguageName.wrappedValue else {
|
|
||||||
return CultureDto.none
|
|
||||||
}
|
|
||||||
return CultureDto(twoLetterISOLanguageName: code)
|
|
||||||
},
|
|
||||||
set: { newCountry in
|
|
||||||
twoLetterISOLanguageName.wrappedValue = newCountry?.twoLetterISOLanguageName
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
self._viewModel = StateObject(
|
|
||||||
wrappedValue: CulturesViewModel(
|
|
||||||
initialValue: [.none]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
init(_ title: String, threeLetterISOLanguageName: Binding<String?>) {
|
|
||||||
self.title = title
|
|
||||||
self._selection = State(
|
|
||||||
initialValue: threeLetterISOLanguageName.wrappedValue.flatMap {
|
|
||||||
CultureDto(threeLetterISOLanguageName: $0)
|
|
||||||
} ?? CultureDto.none
|
|
||||||
)
|
|
||||||
|
|
||||||
self.selectionBinding = Binding<CultureDto?>(
|
|
||||||
get: {
|
|
||||||
guard let code = threeLetterISOLanguageName.wrappedValue else {
|
|
||||||
return CultureDto.none
|
|
||||||
}
|
|
||||||
return CultureDto(threeLetterISOLanguageName: code)
|
|
||||||
},
|
|
||||||
set: { newCountry in
|
|
||||||
threeLetterISOLanguageName.wrappedValue = newCountry?.threeLetterISOLanguageName
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
self._viewModel = StateObject(
|
|
||||||
wrappedValue: CulturesViewModel(
|
|
||||||
initialValue: [.none]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,120 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import JellyfinAPI
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct ParentalRatingPicker: View {
|
|
||||||
|
|
||||||
// MARK: - State Objects
|
|
||||||
|
|
||||||
@StateObject
|
|
||||||
private var viewModel: ParentalRatingsViewModel
|
|
||||||
|
|
||||||
// MARK: - Input Properties
|
|
||||||
|
|
||||||
private var selectionBinding: Binding<ParentalRating?>
|
|
||||||
private let title: String
|
|
||||||
|
|
||||||
@State
|
|
||||||
private var selection: ParentalRating?
|
|
||||||
|
|
||||||
// MARK: - Body
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Group {
|
|
||||||
#if os(tvOS)
|
|
||||||
ListRowMenu(title, subtitle: $selection.wrappedValue?.displayTitle) {
|
|
||||||
Picker(title, selection: $selection) {
|
|
||||||
Text(ParentalRating.none.displayTitle)
|
|
||||||
.tag(ParentalRating.none as ParentalRating?)
|
|
||||||
|
|
||||||
ForEach(viewModel.value, id: \.self) { value in
|
|
||||||
Text(value.displayTitle)
|
|
||||||
.tag(value as ParentalRating?)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: viewModel.value) {
|
|
||||||
updateSelection()
|
|
||||||
}
|
|
||||||
.onChange(of: selection) { _, newValue in
|
|
||||||
selectionBinding.wrappedValue = newValue
|
|
||||||
}
|
|
||||||
.menuStyle(.borderlessButton)
|
|
||||||
.listRowInsets(.zero)
|
|
||||||
#else
|
|
||||||
Picker(title, selection: $selection) {
|
|
||||||
|
|
||||||
Text(ParentalRating.none.displayTitle)
|
|
||||||
.tag(ParentalRating.none as ParentalRating?)
|
|
||||||
|
|
||||||
ForEach(viewModel.value, id: \.self) { value in
|
|
||||||
Text(value.displayTitle)
|
|
||||||
.tag(value as ParentalRating?)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: viewModel.value) { _ in
|
|
||||||
updateSelection()
|
|
||||||
}
|
|
||||||
.onChange(of: selection) { newValue in
|
|
||||||
selectionBinding.wrappedValue = newValue
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
.onFirstAppear {
|
|
||||||
viewModel.refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Update Selection
|
|
||||||
|
|
||||||
private func updateSelection() {
|
|
||||||
let newValue = viewModel.value.first { value in
|
|
||||||
if let selectedName = selection?.name,
|
|
||||||
let candidateName = value.name,
|
|
||||||
selectedName == candidateName
|
|
||||||
{
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
selection = newValue ?? ParentalRating.none
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ParentalRatingPicker {
|
|
||||||
|
|
||||||
init(_ title: String, name: Binding<String?>) {
|
|
||||||
self.title = title
|
|
||||||
self._selection = State(
|
|
||||||
initialValue: name.wrappedValue.flatMap {
|
|
||||||
ParentalRating(name: $0)
|
|
||||||
} ?? ParentalRating.none
|
|
||||||
)
|
|
||||||
|
|
||||||
self.selectionBinding = Binding<ParentalRating?>(
|
|
||||||
get: {
|
|
||||||
guard let ratingName = name.wrappedValue else {
|
|
||||||
return ParentalRating.none
|
|
||||||
}
|
|
||||||
return ParentalRating(name: ratingName)
|
|
||||||
},
|
|
||||||
set: { newRating in
|
|
||||||
name.wrappedValue = newRating?.name
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
self._viewModel = StateObject(
|
|
||||||
wrappedValue: ParentalRatingsViewModel(
|
|
||||||
initialValue: [.none]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,91 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
/// A `VStack` that displays subviews with a marker on the top leading edge.
|
|
||||||
///
|
|
||||||
/// In a marker view, ensure that views that are only used for layout are
|
|
||||||
/// tagged with `hidden` to avoid them being read by accessibility features.
|
|
||||||
struct MarkedList<Content: View, Marker: View>: View {
|
|
||||||
|
|
||||||
private let content: Content
|
|
||||||
private let marker: (Int) -> Marker
|
|
||||||
private let spacing: CGFloat
|
|
||||||
|
|
||||||
init(
|
|
||||||
spacing: CGFloat,
|
|
||||||
@ViewBuilder marker: @escaping (Int) -> Marker,
|
|
||||||
@ViewBuilder content: @escaping () -> Content
|
|
||||||
) {
|
|
||||||
self.marker = marker
|
|
||||||
self.content = content()
|
|
||||||
self.spacing = spacing
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
_VariadicView.Tree(
|
|
||||||
MarkedListLayout(
|
|
||||||
spacing: spacing,
|
|
||||||
marker: marker
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MarkedList {
|
|
||||||
|
|
||||||
struct MarkedListLayout: _VariadicView_UnaryViewRoot {
|
|
||||||
|
|
||||||
let spacing: CGFloat
|
|
||||||
let marker: (Int) -> Marker
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func body(children: _VariadicView.Children) -> some View {
|
|
||||||
VStack(alignment: .leading, spacing: spacing) {
|
|
||||||
ForEach(Array(zip(children.indices, children)), id: \.0) { child in
|
|
||||||
MarkedListEntry(
|
|
||||||
marker: marker(child.0),
|
|
||||||
content: child.1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct MarkedListEntry<EntryContent: View>: View {
|
|
||||||
|
|
||||||
@State
|
|
||||||
private var markerSize: CGSize = .zero
|
|
||||||
@State
|
|
||||||
private var childSize: CGSize = .zero
|
|
||||||
|
|
||||||
let marker: Marker
|
|
||||||
let content: EntryContent
|
|
||||||
|
|
||||||
private var _bullet: some View {
|
|
||||||
marker
|
|
||||||
.trackingSize($markerSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: this can cause clipping issues with text since
|
|
||||||
// with .offset, find fix
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
content
|
|
||||||
.trackingSize($childSize)
|
|
||||||
.overlay(alignment: .topLeading) {
|
|
||||||
_bullet
|
|
||||||
.offset(x: -markerSize.width)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,111 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import AVKit
|
|
||||||
import Factory
|
|
||||||
import JellyfinAPI
|
|
||||||
import Logging
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
// TODO: remove
|
|
||||||
|
|
||||||
struct NativeVideoPlayer: View {
|
|
||||||
|
|
||||||
@Environment(\.presentationCoordinator)
|
|
||||||
private var presentationCoordinator
|
|
||||||
|
|
||||||
@InjectedObject(\.mediaPlayerManager)
|
|
||||||
private var manager: MediaPlayerManager
|
|
||||||
|
|
||||||
@LazyState
|
|
||||||
private var proxy: AVMediaPlayerProxy
|
|
||||||
|
|
||||||
@Router
|
|
||||||
private var router
|
|
||||||
|
|
||||||
init() {
|
|
||||||
self._proxy = .init(wrappedValue: AVMediaPlayerProxy())
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
|
|
||||||
Color.black
|
|
||||||
|
|
||||||
switch manager.state {
|
|
||||||
case .playback:
|
|
||||||
NativeVideoPlayerView(proxy: proxy)
|
|
||||||
default:
|
|
||||||
ProgressView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
manager.proxy = proxy
|
|
||||||
manager.start()
|
|
||||||
}
|
|
||||||
.preference(key: IsStatusBarHiddenKey.self, value: true)
|
|
||||||
.backport
|
|
||||||
.onChange(of: presentationCoordinator.isPresented) { _, isPresented in
|
|
||||||
Container.shared.mediaPlayerManager.reset()
|
|
||||||
guard !isPresented else { return }
|
|
||||||
manager.stop()
|
|
||||||
}
|
|
||||||
.alert(
|
|
||||||
L10n.error,
|
|
||||||
isPresented: .constant(manager.error != nil)
|
|
||||||
) {
|
|
||||||
Button(L10n.close, role: .cancel) {
|
|
||||||
Container.shared.mediaPlayerManager.reset()
|
|
||||||
router.dismiss()
|
|
||||||
}
|
|
||||||
} message: {
|
|
||||||
// TODO: localize
|
|
||||||
Text("Unable to load this item.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension NativeVideoPlayer {
|
|
||||||
|
|
||||||
private struct NativeVideoPlayerView: UIViewControllerRepresentable {
|
|
||||||
|
|
||||||
let proxy: AVMediaPlayerProxy
|
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> UINativeVideoPlayerViewController {
|
|
||||||
UINativeVideoPlayerViewController(proxy: proxy)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIViewController(_ uiViewController: UINativeVideoPlayerViewController, context: Context) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class UINativeVideoPlayerViewController: AVPlayerViewController {
|
|
||||||
|
|
||||||
private let proxy: AVMediaPlayerProxy
|
|
||||||
|
|
||||||
init(proxy: AVMediaPlayerProxy) {
|
|
||||||
self.proxy = proxy
|
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
|
||||||
|
|
||||||
player = proxy.player
|
|
||||||
|
|
||||||
player?.allowsExternalPlayback = true
|
|
||||||
player?.appliesMediaSelectionCriteriaAutomatically = false
|
|
||||||
allowsPictureInPicturePlayback = true
|
|
||||||
|
|
||||||
#if !os(tvOS)
|
|
||||||
updatesNowPlayingInfoCenter = false
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,90 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import BlurHashKit
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
/// Retrieving images by exact pixel dimensions is a bit
|
|
||||||
/// intense for normal usage and eases cache usage and modifications.
|
|
||||||
private let landscapeMaxWidth: CGFloat = 300
|
|
||||||
private let portraitMaxWidth: CGFloat = 200
|
|
||||||
|
|
||||||
struct PosterImage<Item: Poster>: View {
|
|
||||||
|
|
||||||
private let contentMode: ContentMode
|
|
||||||
private let imageMaxWidth: CGFloat
|
|
||||||
private let item: Item
|
|
||||||
private let type: PosterDisplayType
|
|
||||||
|
|
||||||
init(
|
|
||||||
item: Item,
|
|
||||||
type: PosterDisplayType,
|
|
||||||
contentMode: ContentMode = .fill,
|
|
||||||
maxWidth: CGFloat? = nil
|
|
||||||
) {
|
|
||||||
self.contentMode = contentMode
|
|
||||||
self.imageMaxWidth = maxWidth ?? (type == .landscape ? landscapeMaxWidth : portraitMaxWidth)
|
|
||||||
self.item = item
|
|
||||||
self.type = type
|
|
||||||
}
|
|
||||||
|
|
||||||
private var imageSources: [ImageSource] {
|
|
||||||
switch type {
|
|
||||||
case .landscape:
|
|
||||||
item.landscapeImageSources(maxWidth: imageMaxWidth, quality: 90)
|
|
||||||
case .portrait:
|
|
||||||
item.portraitImageSources(maxWidth: imageMaxWidth, quality: 90)
|
|
||||||
case .square:
|
|
||||||
item.squareImageSources(maxWidth: imageMaxWidth, quality: 90)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
Rectangle()
|
|
||||||
.fill(.complexSecondary)
|
|
||||||
|
|
||||||
AlternateLayoutView {
|
|
||||||
Color.clear
|
|
||||||
} content: {
|
|
||||||
ImageView(imageSources)
|
|
||||||
.image(item.transform)
|
|
||||||
.placeholder { imageSource in
|
|
||||||
if let blurHash = imageSource.blurHash {
|
|
||||||
BlurHashView(blurHash: blurHash)
|
|
||||||
} else if item.showTitle {
|
|
||||||
SystemImageContentView(
|
|
||||||
systemName: item.systemImage
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
SystemImageContentView(
|
|
||||||
title: item.displayTitle,
|
|
||||||
systemName: item.systemImage
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.failure {
|
|
||||||
if item.showTitle {
|
|
||||||
SystemImageContentView(
|
|
||||||
systemName: item.systemImage
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
SystemImageContentView(
|
|
||||||
title: item.displayTitle,
|
|
||||||
systemName: item.systemImage
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.posterStyle(
|
|
||||||
type,
|
|
||||||
contentMode: contentMode
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,27 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct TintedMaterial: UIViewRepresentable {
|
|
||||||
|
|
||||||
let tint: Color
|
|
||||||
|
|
||||||
func makeUIView(context: Context) -> UIVisualEffectView {
|
|
||||||
UIVisualEffectView(effect: UIBlurEffect(style: .extraLight))
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIView(_ uiView: UIVisualEffectView, context: Context) {
|
|
||||||
set(tint: tint, for: uiView)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func set(tint: Color, for view: UIVisualEffectView) {
|
|
||||||
let overlayView = view.subviews.first { type(of: $0) == NSClassFromString("_UIVisualEffectSubview") }
|
|
||||||
overlayView?.backgroundColor = UIColor(tint.opacity(0.75))
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,72 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import PulseUI
|
|
||||||
import Stinsen
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
final class AppSettingsCoordinator: NavigationCoordinatable {
|
|
||||||
|
|
||||||
let stack = NavigationStack(initial: \AppSettingsCoordinator.start)
|
|
||||||
|
|
||||||
@Root
|
|
||||||
var start = makeStart
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
@Route(.push)
|
|
||||||
var about = makeAbout
|
|
||||||
@Route(.push)
|
|
||||||
var appIconSelector = makeAppIconSelector
|
|
||||||
@Route(.push)
|
|
||||||
var log = makeLog
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if os(tvOS)
|
|
||||||
@Route(.push)
|
|
||||||
var log = makeLog
|
|
||||||
|
|
||||||
@Route(.fullScreen)
|
|
||||||
var hourPicker = makeHourPicker
|
|
||||||
#endif
|
|
||||||
|
|
||||||
init() {}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
@ViewBuilder
|
|
||||||
func makeAbout(viewModel: SettingsViewModel) -> some View {
|
|
||||||
AboutAppView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeAppIconSelector(viewModel: SettingsViewModel) -> some View {
|
|
||||||
AppIconSelectorView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeLog() -> some View {
|
|
||||||
ConsoleView()
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeStart() -> some View {
|
|
||||||
AppSettingsView()
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(tvOS)
|
|
||||||
@ViewBuilder
|
|
||||||
func makeHourPicker() -> some View {
|
|
||||||
ZStack {
|
|
||||||
BlurView()
|
|
||||||
.ignoresSafeArea()
|
|
||||||
|
|
||||||
HourMinutePicker()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
|
@ -1,72 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import PulseUI
|
|
||||||
import Stinsen
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
final class AppSettingsCoordinator: NavigationCoordinatable {
|
|
||||||
|
|
||||||
let stack = NavigationStack(initial: \AppSettingsCoordinator.start)
|
|
||||||
|
|
||||||
@Root
|
|
||||||
var start = makeStart
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
@Route(.push)
|
|
||||||
var about = makeAbout
|
|
||||||
@Route(.push)
|
|
||||||
var appIconSelector = makeAppIconSelector
|
|
||||||
@Route(.push)
|
|
||||||
var log = makeLog
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if os(tvOS)
|
|
||||||
@Route(.push)
|
|
||||||
var log = makeLog
|
|
||||||
|
|
||||||
@Route(.fullScreen)
|
|
||||||
var hourPicker = makeHourPicker
|
|
||||||
#endif
|
|
||||||
|
|
||||||
init() {}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
@ViewBuilder
|
|
||||||
func makeAbout(viewModel: SettingsViewModel) -> some View {
|
|
||||||
AboutAppView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeAppIconSelector(viewModel: SettingsViewModel) -> some View {
|
|
||||||
AppIconSelectorView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeLog() -> some View {
|
|
||||||
ConsoleView()
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeStart() -> some View {
|
|
||||||
AppSettingsView()
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(tvOS)
|
|
||||||
@ViewBuilder
|
|
||||||
func makeHourPicker() -> some View {
|
|
||||||
ZStack {
|
|
||||||
BlurView()
|
|
||||||
.ignoresSafeArea()
|
|
||||||
|
|
||||||
HourMinutePicker()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
|
@ -27,7 +27,7 @@ final class AppSettingsCoordinator: NavigationCoordinatable {
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
@Route(.push)
|
@Route(.modal)
|
||||||
var log = makeLog
|
var log = makeLog
|
||||||
|
|
||||||
@Route(.fullScreen)
|
@Route(.fullScreen)
|
||||||
|
|
|
@ -1,45 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import JellyfinAPI
|
|
||||||
import Stinsen
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
final class HomeCoordinator: NavigationCoordinatable {
|
|
||||||
|
|
||||||
let stack = NavigationStack(initial: \HomeCoordinator.start)
|
|
||||||
|
|
||||||
@Root
|
|
||||||
var start = makeStart
|
|
||||||
|
|
||||||
#if os(tvOS)
|
|
||||||
@Route(.push)
|
|
||||||
var item = makeItem
|
|
||||||
@Route(.push)
|
|
||||||
var library = makeLibrary
|
|
||||||
#else
|
|
||||||
@Route(.push)
|
|
||||||
var item = makeItem
|
|
||||||
@Route(.push)
|
|
||||||
var library = makeLibrary
|
|
||||||
#endif
|
|
||||||
|
|
||||||
func makeItem(item: BaseItemDto) -> ItemCoordinator {
|
|
||||||
ItemCoordinator(item: item)
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
|
|
||||||
LibraryCoordinator(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeStart() -> some View {
|
|
||||||
HomeView()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,45 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import JellyfinAPI
|
|
||||||
import Stinsen
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
final class HomeCoordinator: NavigationCoordinatable {
|
|
||||||
|
|
||||||
let stack = NavigationStack(initial: \HomeCoordinator.start)
|
|
||||||
|
|
||||||
@Root
|
|
||||||
var start = makeStart
|
|
||||||
|
|
||||||
#if os(tvOS)
|
|
||||||
@Route(.push)
|
|
||||||
var item = makeItem
|
|
||||||
@Route(.push)
|
|
||||||
var library = makeLibrary
|
|
||||||
#else
|
|
||||||
@Route(.push)
|
|
||||||
var item = makeItem
|
|
||||||
@Route(.push)
|
|
||||||
var library = makeLibrary
|
|
||||||
#endif
|
|
||||||
|
|
||||||
func makeItem(item: BaseItemDto) -> ItemCoordinator {
|
|
||||||
ItemCoordinator(item: item)
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
|
|
||||||
LibraryCoordinator(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeStart() -> some View {
|
|
||||||
HomeView()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -19,9 +19,9 @@ final class HomeCoordinator: NavigationCoordinatable {
|
||||||
var start = makeStart
|
var start = makeStart
|
||||||
|
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
@Route(.push)
|
@Route(.modal)
|
||||||
var item = makeItem
|
var item = makeItem
|
||||||
@Route(.push)
|
@Route(.modal)
|
||||||
var library = makeLibrary
|
var library = makeLibrary
|
||||||
#else
|
#else
|
||||||
@Route(.push)
|
@Route(.push)
|
||||||
|
@ -30,6 +30,15 @@ final class HomeCoordinator: NavigationCoordinatable {
|
||||||
var library = makeLibrary
|
var library = makeLibrary
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
func makeItem(item: BaseItemDto) -> NavigationViewCoordinator<ItemCoordinator> {
|
||||||
|
NavigationViewCoordinator(ItemCoordinator(item: item))
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> NavigationViewCoordinator<LibraryCoordinator<BaseItemDto>> {
|
||||||
|
NavigationViewCoordinator(LibraryCoordinator<BaseItemDto>(viewModel: viewModel))
|
||||||
|
}
|
||||||
|
#else
|
||||||
func makeItem(item: BaseItemDto) -> ItemCoordinator {
|
func makeItem(item: BaseItemDto) -> ItemCoordinator {
|
||||||
ItemCoordinator(item: item)
|
ItemCoordinator(item: item)
|
||||||
}
|
}
|
||||||
|
@ -37,6 +46,7 @@ final class HomeCoordinator: NavigationCoordinatable {
|
||||||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
|
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
|
||||||
LibraryCoordinator(viewModel: viewModel)
|
LibraryCoordinator(viewModel: viewModel)
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func makeStart() -> some View {
|
func makeStart() -> some View {
|
||||||
|
|
|
@ -1,60 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import Defaults
|
|
||||||
import Foundation
|
|
||||||
import JellyfinAPI
|
|
||||||
import Stinsen
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
final class LibraryCoordinator<Element: Poster>: NavigationCoordinatable {
|
|
||||||
|
|
||||||
let stack = NavigationStack(initial: \LibraryCoordinator.start)
|
|
||||||
|
|
||||||
@Root
|
|
||||||
var start = makeStart
|
|
||||||
|
|
||||||
#if os(tvOS)
|
|
||||||
@Route(.push)
|
|
||||||
var item = makeItem
|
|
||||||
@Route(.push)
|
|
||||||
var library = makeLibrary
|
|
||||||
#else
|
|
||||||
@Route(.push)
|
|
||||||
var item = makeItem
|
|
||||||
@Route(.push)
|
|
||||||
var library = makeLibrary
|
|
||||||
@Route(.modal)
|
|
||||||
var filter = makeFilter
|
|
||||||
#endif
|
|
||||||
|
|
||||||
private let viewModel: PagingLibraryViewModel<Element>
|
|
||||||
|
|
||||||
init(viewModel: PagingLibraryViewModel<Element>) {
|
|
||||||
self.viewModel = viewModel
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeStart() -> some View {
|
|
||||||
PagingLibraryView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeItem(item: BaseItemDto) -> ItemCoordinator {
|
|
||||||
ItemCoordinator(item: item)
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
|
|
||||||
LibraryCoordinator<BaseItemDto>(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
#if !os(tvOS)
|
|
||||||
func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator<FilterCoordinator> {
|
|
||||||
NavigationViewCoordinator(FilterCoordinator(parameters: parameters))
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
|
@ -1,60 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import Defaults
|
|
||||||
import Foundation
|
|
||||||
import JellyfinAPI
|
|
||||||
import Stinsen
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
final class LibraryCoordinator<Element: Poster>: NavigationCoordinatable {
|
|
||||||
|
|
||||||
let stack = NavigationStack(initial: \LibraryCoordinator.start)
|
|
||||||
|
|
||||||
@Root
|
|
||||||
var start = makeStart
|
|
||||||
|
|
||||||
#if os(tvOS)
|
|
||||||
@Route(.push)
|
|
||||||
var item = makeItem
|
|
||||||
@Route(.push)
|
|
||||||
var library = makeLibrary
|
|
||||||
#else
|
|
||||||
@Route(.push)
|
|
||||||
var item = makeItem
|
|
||||||
@Route(.push)
|
|
||||||
var library = makeLibrary
|
|
||||||
@Route(.modal)
|
|
||||||
var filter = makeFilter
|
|
||||||
#endif
|
|
||||||
|
|
||||||
private let viewModel: PagingLibraryViewModel<Element>
|
|
||||||
|
|
||||||
init(viewModel: PagingLibraryViewModel<Element>) {
|
|
||||||
self.viewModel = viewModel
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeStart() -> some View {
|
|
||||||
PagingLibraryView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeItem(item: BaseItemDto) -> ItemCoordinator {
|
|
||||||
ItemCoordinator(item: item)
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
|
|
||||||
LibraryCoordinator<BaseItemDto>(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
#if !os(tvOS)
|
|
||||||
func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator<FilterCoordinator> {
|
|
||||||
NavigationViewCoordinator(FilterCoordinator(parameters: parameters))
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
|
@ -20,7 +20,7 @@ final class LibraryCoordinator<Element: Poster>: NavigationCoordinatable {
|
||||||
var start = makeStart
|
var start = makeStart
|
||||||
|
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
@Route(.push)
|
@Route(.modal)
|
||||||
var item = makeItem
|
var item = makeItem
|
||||||
@Route(.push)
|
@Route(.push)
|
||||||
var library = makeLibrary
|
var library = makeLibrary
|
||||||
|
@ -44,6 +44,15 @@ final class LibraryCoordinator<Element: Poster>: NavigationCoordinatable {
|
||||||
PagingLibraryView(viewModel: viewModel)
|
PagingLibraryView(viewModel: viewModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
func makeItem(item: BaseItemDto) -> NavigationViewCoordinator<ItemCoordinator> {
|
||||||
|
NavigationViewCoordinator(ItemCoordinator(item: item))
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> NavigationViewCoordinator<LibraryCoordinator<BaseItemDto>> {
|
||||||
|
NavigationViewCoordinator(LibraryCoordinator<BaseItemDto>(viewModel: viewModel))
|
||||||
|
}
|
||||||
|
#else
|
||||||
func makeItem(item: BaseItemDto) -> ItemCoordinator {
|
func makeItem(item: BaseItemDto) -> ItemCoordinator {
|
||||||
ItemCoordinator(item: item)
|
ItemCoordinator(item: item)
|
||||||
}
|
}
|
||||||
|
@ -52,7 +61,6 @@ final class LibraryCoordinator<Element: Poster>: NavigationCoordinatable {
|
||||||
LibraryCoordinator<BaseItemDto>(viewModel: viewModel)
|
LibraryCoordinator<BaseItemDto>(viewModel: viewModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
#if !os(tvOS)
|
|
||||||
func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator<FilterCoordinator> {
|
func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator<FilterCoordinator> {
|
||||||
NavigationViewCoordinator(FilterCoordinator(parameters: parameters))
|
NavigationViewCoordinator(FilterCoordinator(parameters: parameters))
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,25 +13,24 @@ import SwiftUI
|
||||||
final class LiveTVCoordinator: TabCoordinatable {
|
final class LiveTVCoordinator: TabCoordinatable {
|
||||||
|
|
||||||
var child = TabChild(startingItems: [
|
var child = TabChild(startingItems: [
|
||||||
|
\LiveTVCoordinator.programs,
|
||||||
\LiveTVCoordinator.channels,
|
\LiveTVCoordinator.channels,
|
||||||
\LiveTVCoordinator.programGuide,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
@Route(tabItem: makeProgramGuideTab)
|
@Route(tabItem: makeProgramsTab)
|
||||||
var programGuide = makeProgramGuide
|
var programs = makePrograms
|
||||||
|
|
||||||
@Route(tabItem: makeChannelsTab)
|
@Route(tabItem: makeChannelsTab)
|
||||||
var channels = makeChannels
|
var channels = makeChannels
|
||||||
|
|
||||||
func makeProgramGuide() -> VideoPlayerWrapperCoordinator {
|
func makePrograms() -> VideoPlayerWrapperCoordinator {
|
||||||
VideoPlayerWrapperCoordinator {
|
VideoPlayerWrapperCoordinator {
|
||||||
ProgramGuideView()
|
ProgramsView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func makeProgramGuideTab(isActive: Bool) -> some View {
|
func makeProgramsTab(isActive: Bool) -> some View {
|
||||||
Label("Guide", systemImage: "list.bullet.rectangle")
|
Label(L10n.programs, systemImage: "tv")
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeChannels() -> VideoPlayerWrapperCoordinator {
|
func makeChannels() -> VideoPlayerWrapperCoordinator {
|
||||||
|
|
|
@ -69,8 +69,6 @@ final class LiveVideoPlayerCoordinator: NavigationCoordinatable {
|
||||||
#else
|
#else
|
||||||
|
|
||||||
PreferencesView {
|
PreferencesView {
|
||||||
// Use VLC for Live TV to handle raw MPEG-TS streams from Dispatcharr
|
|
||||||
// (Native AVPlayer can't play raw MPEG-TS, only HLS)
|
|
||||||
if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin {
|
if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin {
|
||||||
LiveVideoPlayer(manager: self.videoPlayerManager)
|
LiveVideoPlayer(manager: self.videoPlayerManager)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -1,56 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import JellyfinAPI
|
|
||||||
import Stinsen
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
final class MediaCoordinator: NavigationCoordinatable {
|
|
||||||
|
|
||||||
let stack = NavigationStack(initial: \MediaCoordinator.start)
|
|
||||||
|
|
||||||
@Root
|
|
||||||
var start = makeStart
|
|
||||||
#if os(tvOS)
|
|
||||||
@Route(.fullScreen)
|
|
||||||
var library = makeLibrary
|
|
||||||
@Route(.fullScreen)
|
|
||||||
var liveTV = makeLiveTV
|
|
||||||
#else
|
|
||||||
@Route(.push)
|
|
||||||
var library = makeLibrary
|
|
||||||
@Route(.push)
|
|
||||||
var liveTV = makeLiveTV
|
|
||||||
@Route(.push)
|
|
||||||
var downloads = makeDownloads
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if os(tvOS)
|
|
||||||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> NavigationViewCoordinator<LibraryCoordinator<BaseItemDto>> {
|
|
||||||
NavigationViewCoordinator(LibraryCoordinator(viewModel: viewModel))
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
|
|
||||||
LibraryCoordinator(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeDownloads() -> DownloadListCoordinator {
|
|
||||||
DownloadListCoordinator()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
func makeLiveTV() -> LiveTVCoordinator {
|
|
||||||
LiveTVCoordinator()
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeStart() -> some View {
|
|
||||||
MediaView()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,56 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import JellyfinAPI
|
|
||||||
import Stinsen
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
final class MediaCoordinator: NavigationCoordinatable {
|
|
||||||
|
|
||||||
let stack = NavigationStack(initial: \MediaCoordinator.start)
|
|
||||||
|
|
||||||
@Root
|
|
||||||
var start = makeStart
|
|
||||||
#if os(tvOS)
|
|
||||||
@Route(.fullScreen)
|
|
||||||
var library = makeLibrary
|
|
||||||
@Route(.fullScreen)
|
|
||||||
var liveTV = makeLiveTV
|
|
||||||
#else
|
|
||||||
@Route(.push)
|
|
||||||
var library = makeLibrary
|
|
||||||
@Route(.push)
|
|
||||||
var liveTV = makeLiveTV
|
|
||||||
@Route(.push)
|
|
||||||
var downloads = makeDownloads
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if os(tvOS)
|
|
||||||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> NavigationViewCoordinator<LibraryCoordinator<BaseItemDto>> {
|
|
||||||
NavigationViewCoordinator(LibraryCoordinator(viewModel: viewModel))
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
|
|
||||||
LibraryCoordinator(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeDownloads() -> DownloadListCoordinator {
|
|
||||||
DownloadListCoordinator()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
func makeLiveTV() -> LiveTVCoordinator {
|
|
||||||
LiveTVCoordinator()
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeStart() -> some View {
|
|
||||||
MediaView()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -18,9 +18,9 @@ final class MediaCoordinator: NavigationCoordinatable {
|
||||||
@Root
|
@Root
|
||||||
var start = makeStart
|
var start = makeStart
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
@Route(.fullScreen)
|
@Route(.push)
|
||||||
var library = makeLibrary
|
var library = makeLibrary
|
||||||
@Route(.fullScreen)
|
@Route(.push)
|
||||||
var liveTV = makeLiveTV
|
var liveTV = makeLiveTV
|
||||||
#else
|
#else
|
||||||
@Route(.push)
|
@Route(.push)
|
||||||
|
|
|
@ -1,47 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class NavigationCoordinator: ObservableObject {
|
|
||||||
|
|
||||||
@Published
|
|
||||||
var path: [NavigationRoute] = []
|
|
||||||
|
|
||||||
@Published
|
|
||||||
var presentedSheet: NavigationRoute?
|
|
||||||
@Published
|
|
||||||
var presentedFullScreen: NavigationRoute?
|
|
||||||
|
|
||||||
func push(
|
|
||||||
_ route: NavigationRoute
|
|
||||||
) {
|
|
||||||
let style = route.transitionStyle
|
|
||||||
|
|
||||||
#if os(tvOS)
|
|
||||||
switch style {
|
|
||||||
case .push, .sheet:
|
|
||||||
presentedSheet = route
|
|
||||||
case .fullscreen:
|
|
||||||
presentedFullScreen = route
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
switch style {
|
|
||||||
case .push:
|
|
||||||
path.append(route)
|
|
||||||
case .sheet:
|
|
||||||
presentedSheet = route
|
|
||||||
case .fullscreen:
|
|
||||||
withAnimation {
|
|
||||||
presentedFullScreen = route
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,106 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import PreferencesView
|
|
||||||
import SwiftUI
|
|
||||||
import Transmission
|
|
||||||
|
|
||||||
// TODO: have full screen zoom presentation zoom from/to center
|
|
||||||
// - probably need to make mock view with matching ids
|
|
||||||
// TODO: have presentation dismissal be through preference keys
|
|
||||||
// - issue with all of the VC/view wrapping
|
|
||||||
|
|
||||||
extension EnvironmentValues {
|
|
||||||
|
|
||||||
@Entry
|
|
||||||
var presentationControllerShouldDismiss: Binding<Bool> = .constant(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct NavigationInjectionView: View {
|
|
||||||
|
|
||||||
@StateObject
|
|
||||||
private var coordinator: NavigationCoordinator
|
|
||||||
@EnvironmentObject
|
|
||||||
private var rootCoordinator: RootCoordinator
|
|
||||||
|
|
||||||
@State
|
|
||||||
private var isPresentationInteractive: Bool = true
|
|
||||||
|
|
||||||
private let content: AnyView
|
|
||||||
|
|
||||||
init(
|
|
||||||
coordinator: @autoclosure @escaping () -> NavigationCoordinator,
|
|
||||||
@ViewBuilder content: @escaping () -> some View
|
|
||||||
) {
|
|
||||||
_coordinator = StateObject(wrappedValue: coordinator())
|
|
||||||
self.content = AnyView(content())
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationStack(path: $coordinator.path) {
|
|
||||||
content
|
|
||||||
.navigationDestination(for: NavigationRoute.self) { route in
|
|
||||||
route.destination
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.environment(
|
|
||||||
\.router,
|
|
||||||
.init(
|
|
||||||
navigationCoordinator: coordinator,
|
|
||||||
rootCoordinator: rootCoordinator
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.sheet(
|
|
||||||
item: $coordinator.presentedSheet
|
|
||||||
) {
|
|
||||||
coordinator.presentedSheet = nil
|
|
||||||
} content: { route in
|
|
||||||
let newCoordinator = NavigationCoordinator()
|
|
||||||
|
|
||||||
NavigationInjectionView(coordinator: newCoordinator) {
|
|
||||||
route.destination
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#if os(tvOS)
|
|
||||||
.fullScreenCover(
|
|
||||||
item: $coordinator.presentedFullScreen
|
|
||||||
) { route in
|
|
||||||
let newCoordinator = NavigationCoordinator()
|
|
||||||
|
|
||||||
NavigationInjectionView(coordinator: newCoordinator) {
|
|
||||||
route.destination
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
.presentation(
|
|
||||||
$coordinator.presentedFullScreen,
|
|
||||||
transition: .zoomIfAvailable(
|
|
||||||
options: .init(
|
|
||||||
dimmingVisualEffect: .systemThickMaterialDark,
|
|
||||||
options: .init(
|
|
||||||
isInteractive: isPresentationInteractive
|
|
||||||
)
|
|
||||||
),
|
|
||||||
otherwise: .slide(.init(edge: .bottom), options: .init(isInteractive: isPresentationInteractive))
|
|
||||||
)
|
|
||||||
) { routeBinding, _ in
|
|
||||||
let vc = UIPreferencesHostingController {
|
|
||||||
NavigationInjectionView(coordinator: .init()) {
|
|
||||||
routeBinding.wrappedValue.destination
|
|
||||||
.environment(\.presentationControllerShouldDismiss, $isPresentationInteractive)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: presentation options for customizing background color, dimming effect, etc.
|
|
||||||
vc.view.backgroundColor = .black
|
|
||||||
|
|
||||||
return vc
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,226 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import JellyfinAPI
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
extension NavigationRoute {
|
|
||||||
|
|
||||||
// MARK: - Active Sessions
|
|
||||||
|
|
||||||
static func activeDeviceDetails(box: BindingBox<SessionInfoDto?>) -> NavigationRoute {
|
|
||||||
NavigationRoute(id: "activeDeviceDetails") {
|
|
||||||
ActiveSessionDetailView(box: box)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static let activeSessions = NavigationRoute(
|
|
||||||
id: "activeSessions"
|
|
||||||
) {
|
|
||||||
ActiveSessionsView()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - User Activity
|
|
||||||
|
|
||||||
static let activity = NavigationRoute(
|
|
||||||
id: "activity"
|
|
||||||
) {
|
|
||||||
ServerActivityView()
|
|
||||||
}
|
|
||||||
|
|
||||||
static func activityDetails(viewModel: ServerActivityDetailViewModel) -> NavigationRoute {
|
|
||||||
NavigationRoute(id: "activityDetails") {
|
|
||||||
ServerActivityDetailsView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func activityFilters(viewModel: ServerActivityViewModel) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "activityFilters",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
ServerActivityFilterView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Server Tasks
|
|
||||||
|
|
||||||
static func addServerTaskTrigger(observer: ServerTaskObserver) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "addServerTaskTrigger",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
AddTaskTriggerView(observer: observer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Users
|
|
||||||
|
|
||||||
static func addServerUser() -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "addServerUser",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
AddServerUserView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - API Keys
|
|
||||||
|
|
||||||
static let apiKeys = NavigationRoute(
|
|
||||||
id: "apiKeys"
|
|
||||||
) {
|
|
||||||
APIKeysView()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Devices
|
|
||||||
|
|
||||||
static func deviceDetails(device: DeviceInfoDto, viewModel: DevicesViewModel) -> NavigationRoute {
|
|
||||||
NavigationRoute(id: "deviceDetails") {
|
|
||||||
DeviceDetailsView(device: device, viewModel: viewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static let devices = NavigationRoute(
|
|
||||||
id: "devices"
|
|
||||||
) {
|
|
||||||
DevicesView()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Server Tasks
|
|
||||||
|
|
||||||
static func editServerTask(observer: ServerTaskObserver) -> NavigationRoute {
|
|
||||||
NavigationRoute(id: "editServerTask") {
|
|
||||||
EditServerTaskView(observer: observer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Users
|
|
||||||
|
|
||||||
static func quickConnectAuthorize(user: UserDto) -> NavigationRoute {
|
|
||||||
NavigationRoute(id: "quickConnectAuthorize") {
|
|
||||||
QuickConnectAuthorizeView(user: user)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func resetUserPasswordAdmin(userID: String) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "resetUserPasswordAdmin",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
ResetUserPasswordView(userID: userID, requiresCurrentPassword: false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Server Logs
|
|
||||||
|
|
||||||
static let serverLogs = NavigationRoute(
|
|
||||||
id: "serverLogs"
|
|
||||||
) {
|
|
||||||
ServerLogsView()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Server Tasks
|
|
||||||
|
|
||||||
static let tasks = NavigationRoute(
|
|
||||||
id: "tasks"
|
|
||||||
) {
|
|
||||||
ServerTasksView()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Users
|
|
||||||
|
|
||||||
static func userAddAccessSchedule(viewModel: ServerUserAdminViewModel) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "userAddAccessSchedule",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
AddAccessScheduleView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func userAddAccessTag(viewModel: ServerUserAdminViewModel) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "userAddAccessTag",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
AddServerUserAccessTagsView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func userDetails(user: UserDto) -> NavigationRoute {
|
|
||||||
NavigationRoute(id: "userDetails") {
|
|
||||||
ServerUserDetailsView(user: user)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func userDeviceAccess(viewModel: ServerUserAdminViewModel) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "userDeviceAccess",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
ServerUserDeviceAccessView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func userEditAccessSchedules(viewModel: ServerUserAdminViewModel) -> NavigationRoute {
|
|
||||||
NavigationRoute(id: "userEditAccessSchedules") {
|
|
||||||
EditAccessScheduleView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func userEditAccessTags(viewModel: ServerUserAdminViewModel) -> NavigationRoute {
|
|
||||||
NavigationRoute(id: "userEditAccessTags") {
|
|
||||||
EditServerUserAccessTagsView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func userLiveTVAccess(viewModel: ServerUserAdminViewModel) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "userLiveTVAccess",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
ServerUserLiveTVAccessView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func userMediaAccess(viewModel: ServerUserAdminViewModel) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "userMediaAccess",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
ServerUserMediaAccessView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func userParentalRatings(viewModel: ServerUserAdminViewModel) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "userParentalRatings",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
ServerUserParentalRatingView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func userPermissions(viewModel: ServerUserAdminViewModel) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "userPermissions",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
ServerUserPermissionsView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static let users = NavigationRoute(
|
|
||||||
id: "users"
|
|
||||||
) {
|
|
||||||
ServerUsersView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
|
@ -1,50 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import JellyfinAPI
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
extension NavigationRoute {
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
static let aboutApp = NavigationRoute(
|
|
||||||
id: "about-app"
|
|
||||||
) {
|
|
||||||
AboutAppView()
|
|
||||||
}
|
|
||||||
|
|
||||||
static func appIconSelector(viewModel: SettingsViewModel) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "app-icon-selector"
|
|
||||||
) {
|
|
||||||
AppIconSelectorView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
static let appSettings = NavigationRoute(
|
|
||||||
id: "app-settings",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
AppSettingsView()
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(tvOS)
|
|
||||||
static let hourPicker = NavigationRoute(
|
|
||||||
id: "hour-picker",
|
|
||||||
style: .fullscreen
|
|
||||||
) {
|
|
||||||
ZStack {
|
|
||||||
BlurView()
|
|
||||||
.ignoresSafeArea()
|
|
||||||
|
|
||||||
HourMinutePicker()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
|
@ -1,34 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import JellyfinAPI
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
extension NavigationRoute {
|
|
||||||
|
|
||||||
static let downloadList = NavigationRoute(
|
|
||||||
id: "downloadList"
|
|
||||||
) {
|
|
||||||
#if os(iOS)
|
|
||||||
DownloadListView(viewModel: .init())
|
|
||||||
#else
|
|
||||||
EmptyView()
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
static func downloadTask(downloadTask: DownloadTask) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "downloadTask",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
DownloadTaskView(downloadTask: downloadTask)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
|
@ -1,275 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import JellyfinAPI
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
extension NavigationRoute {
|
|
||||||
|
|
||||||
// MARK: - Item Editing
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
static func addGenre(viewModel: GenreEditorViewModel) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "addGenre",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
AddItemElementView(viewModel: viewModel, type: .genres)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func addItemImage(viewModel: ItemImagesViewModel, imageType: ImageType) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "addItemImage",
|
|
||||||
style: .push(.automatic)
|
|
||||||
) {
|
|
||||||
AddItemImageView(
|
|
||||||
viewModel: viewModel,
|
|
||||||
imageType: imageType
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func addPeople(viewModel: PeopleEditorViewModel) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "addPeople",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
AddItemElementView(viewModel: viewModel, type: .people)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func addStudio(viewModel: StudioEditorViewModel) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "addStudio",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
AddItemElementView(viewModel: viewModel, type: .studios)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func addTag(viewModel: TagEditorViewModel) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "addTag",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
AddItemElementView(viewModel: viewModel, type: .tags)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
static func castAndCrew(people: [BaseItemPerson], itemID: String?) -> NavigationRoute {
|
|
||||||
let id: String? = itemID == nil ? nil : "castAndCrew-\(itemID!)"
|
|
||||||
let viewModel = PagingLibraryViewModel(
|
|
||||||
title: L10n.castAndCrew,
|
|
||||||
id: id,
|
|
||||||
people
|
|
||||||
)
|
|
||||||
|
|
||||||
return NavigationRoute(id: "castAndCrew") {
|
|
||||||
PagingLibraryView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
static func cropItemImage(viewModel: ItemImagesViewModel, image: UIImage, type: ImageType) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "crop-Image"
|
|
||||||
) {
|
|
||||||
ItemPhotoCropView(
|
|
||||||
viewModel: viewModel,
|
|
||||||
image: image,
|
|
||||||
type: type
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func editGenres(item: BaseItemDto) -> NavigationRoute {
|
|
||||||
NavigationRoute(id: "editGenres") {
|
|
||||||
EditItemElementView<String>(
|
|
||||||
viewModel: GenreEditorViewModel(item: item),
|
|
||||||
type: .genres,
|
|
||||||
route: { router, viewModel in
|
|
||||||
router.route(to: .addGenre(viewModel: viewModel as! GenreEditorViewModel))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func editSubtitles(item: BaseItemDto) -> NavigationRoute {
|
|
||||||
NavigationRoute(id: "editSubtitles") {
|
|
||||||
ItemSubtitlesView(item: item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func uploadSubtitle(viewModel: SubtitleEditorViewModel) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "uploadSubtitle",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
ItemSubtitleUploadView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func editMetadata(item: BaseItemDto) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "editMetadata",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
EditMetadataView(viewModel: ItemEditorViewModel(item: item))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func editPeople(item: BaseItemDto) -> NavigationRoute {
|
|
||||||
NavigationRoute(id: "editPeople") {
|
|
||||||
EditItemElementView<BaseItemPerson>(
|
|
||||||
viewModel: PeopleEditorViewModel(item: item),
|
|
||||||
type: .people,
|
|
||||||
route: { router, viewModel in
|
|
||||||
router.route(to: .addPeople(viewModel: viewModel as! PeopleEditorViewModel))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func editStudios(item: BaseItemDto) -> NavigationRoute {
|
|
||||||
NavigationRoute(id: "editStudios") {
|
|
||||||
EditItemElementView<NameGuidPair>(
|
|
||||||
viewModel: StudioEditorViewModel(item: item),
|
|
||||||
type: .studios,
|
|
||||||
route: { router, viewModel in
|
|
||||||
router.route(to: .addStudio(viewModel: viewModel as! StudioEditorViewModel))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func editTags(item: BaseItemDto) -> NavigationRoute {
|
|
||||||
NavigationRoute(id: "editTags") {
|
|
||||||
EditItemElementView<String>(
|
|
||||||
viewModel: TagEditorViewModel(item: item),
|
|
||||||
type: .tags,
|
|
||||||
route: { router, viewModel in
|
|
||||||
router.route(to: .addTag(viewModel: viewModel as! TagEditorViewModel))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func identifyItem(item: BaseItemDto) -> NavigationRoute {
|
|
||||||
NavigationRoute(id: "identifyItem") {
|
|
||||||
IdentifyItemView(item: item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func identifyItemResults(
|
|
||||||
viewModel: IdentifyItemViewModel,
|
|
||||||
result: RemoteSearchResult
|
|
||||||
) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "identifyItemResults",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
IdentifyItemView.RemoteSearchResultView(
|
|
||||||
viewModel: viewModel,
|
|
||||||
result: result
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
static func searchSubtitle(viewModel: SubtitleEditorViewModel) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "searchSubtitle",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
ItemSubtitleSearchView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func item(item: BaseItemDto) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "item-\(item.id ?? "Unknown")",
|
|
||||||
withNamespace: { .push(.zoom(sourceID: "item", namespace: $0)) }
|
|
||||||
) {
|
|
||||||
ItemView(item: item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
static func itemEditor(viewModel: ItemViewModel) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "itemEditor",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
ItemEditorView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func itemImageDetails(viewModel: ItemImagesViewModel, imageInfo: ImageInfo) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "itemImageDetails",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
ItemImageDetailsView(
|
|
||||||
viewModel: viewModel,
|
|
||||||
imageInfo: imageInfo
|
|
||||||
)
|
|
||||||
.isEditing(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func itemImages(viewModel: ItemImagesViewModel) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "itemImages",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
ItemImagesView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func itemImageSelector(viewModel: ItemImagesViewModel, imageType: ImageType) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "itemImageSelector",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
ItemImagePicker(
|
|
||||||
viewModel: viewModel,
|
|
||||||
type: imageType
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endif
|
|
||||||
|
|
||||||
static func itemOverview(item: BaseItemDto) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "itemOverview",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
ItemOverviewView(item: item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
|
|
||||||
static func itemSearchImageDetails(viewModel: ItemImagesViewModel, remoteImageInfo: RemoteImageInfo) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "itemSearchImageDetails",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
ItemImageDetailsView(
|
|
||||||
viewModel: viewModel,
|
|
||||||
remoteImageInfo: remoteImageInfo
|
|
||||||
)
|
|
||||||
.isEditing(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endif
|
|
||||||
}
|
|
|
@ -1,35 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import JellyfinAPI
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
extension NavigationRoute {
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
static func filter(type: ItemFilterType, viewModel: FilterViewModel) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "filter",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
FilterView(viewModel: viewModel, type: type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
static func library(
|
|
||||||
viewModel: PagingLibraryViewModel<some Poster>
|
|
||||||
) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "library-(\(viewModel.parent?.id ?? "Unparented"))",
|
|
||||||
withNamespace: { .push(.zoom(sourceID: "item", namespace: $0)) }
|
|
||||||
) {
|
|
||||||
PagingLibraryView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,121 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import Defaults
|
|
||||||
import Factory
|
|
||||||
import JellyfinAPI
|
|
||||||
import PreferencesView
|
|
||||||
import SwiftUI
|
|
||||||
import Transmission
|
|
||||||
|
|
||||||
extension NavigationRoute {
|
|
||||||
|
|
||||||
static let channels = NavigationRoute(
|
|
||||||
id: "channels"
|
|
||||||
) {
|
|
||||||
ChannelLibraryView()
|
|
||||||
}
|
|
||||||
|
|
||||||
static let liveTV = NavigationRoute(
|
|
||||||
id: "liveTV"
|
|
||||||
) {
|
|
||||||
ProgramsView()
|
|
||||||
}
|
|
||||||
|
|
||||||
static func mediaSourceInfo(source: MediaSourceInfo) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "mediaSourceInfo",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
MediaSourceInfoView(source: source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
static func mediaStreamInfo(mediaStream: MediaStream) -> NavigationRoute {
|
|
||||||
NavigationRoute(id: "mediaStreamInfo") {
|
|
||||||
MediaStreamInfoView(mediaStream: mediaStream)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
static func videoPlayer(
|
|
||||||
item: BaseItemDto,
|
|
||||||
mediaSource: MediaSourceInfo? = nil,
|
|
||||||
queue: (any MediaPlayerQueue)? = nil
|
|
||||||
) -> NavigationRoute {
|
|
||||||
let provider = MediaPlayerItemProvider(item: item) { item in
|
|
||||||
try await MediaPlayerItem.build(for: item, mediaSource: mediaSource)
|
|
||||||
}
|
|
||||||
return Self.videoPlayer(provider: provider, queue: queue)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
static func videoPlayer(
|
|
||||||
provider: MediaPlayerItemProvider,
|
|
||||||
queue: (any MediaPlayerQueue)? = nil
|
|
||||||
) -> NavigationRoute {
|
|
||||||
let manager = MediaPlayerManager(
|
|
||||||
item: provider.item,
|
|
||||||
queue: queue,
|
|
||||||
mediaPlayerItemProvider: provider.function
|
|
||||||
)
|
|
||||||
|
|
||||||
return Self.videoPlayer(manager: manager)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
static func videoPlayer(manager: MediaPlayerManager) -> NavigationRoute {
|
|
||||||
|
|
||||||
Container.shared.mediaPlayerManager.register {
|
|
||||||
manager
|
|
||||||
}
|
|
||||||
|
|
||||||
Container.shared.mediaPlayerManagerPublisher()
|
|
||||||
.send(manager)
|
|
||||||
|
|
||||||
return NavigationRoute(
|
|
||||||
id: "videoPlayer",
|
|
||||||
style: .fullscreen
|
|
||||||
) {
|
|
||||||
VideoPlayerViewShim(manager: manager)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: shim until native vs swiftfin player is replace with vlc vs av layers
|
|
||||||
// - when removed, ensure same behavior with safe area
|
|
||||||
// - may just need to make a VC wrapper to capture them
|
|
||||||
|
|
||||||
struct VideoPlayerViewShim: View {
|
|
||||||
|
|
||||||
@State
|
|
||||||
private var safeAreaInsets: EdgeInsets = .init()
|
|
||||||
|
|
||||||
let manager: MediaPlayerManager
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Group {
|
|
||||||
if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin {
|
|
||||||
VideoPlayer()
|
|
||||||
} else {
|
|
||||||
NativeVideoPlayer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.colorScheme(.dark) // use over `preferredColorScheme(.dark)` to not have destination change
|
|
||||||
.environment(\.safeAreaInsets, safeAreaInsets)
|
|
||||||
.supportedOrientations(.allButUpsideDown)
|
|
||||||
.ignoresSafeArea()
|
|
||||||
.persistentSystemOverlays(.hidden)
|
|
||||||
.toolbar(.hidden, for: .navigationBar)
|
|
||||||
.onSizeChanged { _, safeArea in
|
|
||||||
self.safeAreaInsets = safeArea.max(EdgeInsets.edgePadding)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,205 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import JellyfinAPI
|
|
||||||
import PulseUI
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
extension NavigationRoute {
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
static func actionButtonSelector(selectedButtonsBinding: Binding<[VideoPlayerActionButton]>) -> NavigationRoute {
|
|
||||||
NavigationRoute(id: "actionButtonSelector") {
|
|
||||||
ActionButtonSelectorView(selection: selectedButtonsBinding)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static let adminDashboard = NavigationRoute(
|
|
||||||
id: "adminDashboard"
|
|
||||||
) {
|
|
||||||
AdminDashboardView()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
static let createCustomDeviceProfile = NavigationRoute(
|
|
||||||
id: "createCustomDeviceProfile",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
CustomDeviceProfileSettingsView.EditCustomDeviceProfileView(profile: nil)
|
|
||||||
.navigationTitle(L10n.customProfile)
|
|
||||||
}
|
|
||||||
|
|
||||||
static let customDeviceProfileSettings = NavigationRoute(
|
|
||||||
id: "customDeviceProfileSettings"
|
|
||||||
) {
|
|
||||||
CustomDeviceProfileSettingsView()
|
|
||||||
}
|
|
||||||
|
|
||||||
static let customizeViewsSettings = NavigationRoute(
|
|
||||||
id: "customizeViewsSettings"
|
|
||||||
) {
|
|
||||||
CustomizeViewsSettings()
|
|
||||||
}
|
|
||||||
|
|
||||||
#if DEBUG && !os(tvOS)
|
|
||||||
static let debugSettings = NavigationRoute(
|
|
||||||
id: "debugSettings"
|
|
||||||
) {
|
|
||||||
DebugSettingsView()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
static func editCustomDeviceProfile(profile: Binding<CustomDeviceProfile>) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "editCustomDeviceProfile",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
CustomDeviceProfileSettingsView.EditCustomDeviceProfileView(profile: profile)
|
|
||||||
.navigationTitle(L10n.customProfile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func editCustomDeviceProfileAudio(selection: Binding<[AudioCodec]>) -> NavigationRoute {
|
|
||||||
NavigationRoute(id: "editCustomDeviceProfileAudio") {
|
|
||||||
OrderedSectionSelectorView(selection: selection, sources: AudioCodec.allCases)
|
|
||||||
.navigationTitle(L10n.audio)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func editCustomDeviceProfileContainer(selection: Binding<[MediaContainer]>) -> NavigationRoute {
|
|
||||||
NavigationRoute(id: "editCustomDeviceProfileContainer") {
|
|
||||||
OrderedSectionSelectorView(selection: selection, sources: MediaContainer.allCases)
|
|
||||||
.navigationTitle(L10n.containers)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func editCustomDeviceProfileVideo(selection: Binding<[VideoCodec]>) -> NavigationRoute {
|
|
||||||
NavigationRoute(id: "editCustomDeviceProfileVideo") {
|
|
||||||
OrderedSectionSelectorView(selection: selection, sources: VideoCodec.allCases)
|
|
||||||
.navigationTitle(L10n.video)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func editServer(server: ServerState, isEditing: Bool = false) -> NavigationRoute {
|
|
||||||
NavigationRoute(id: "editServer") {
|
|
||||||
EditServerView(server: server)
|
|
||||||
.isEditing(isEditing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static let experimentalSettings = NavigationRoute(
|
|
||||||
id: "experimentalSettings"
|
|
||||||
) {
|
|
||||||
ExperimentalSettingsView()
|
|
||||||
}
|
|
||||||
|
|
||||||
static func fontPicker(selection: Binding<String>) -> NavigationRoute {
|
|
||||||
NavigationRoute(id: "fontPicker") {
|
|
||||||
FontPickerView(selection: selection)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
static let gestureSettings = NavigationRoute(
|
|
||||||
id: "gestureSettings"
|
|
||||||
) {
|
|
||||||
GestureSettingsView()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
static let indicatorSettings = NavigationRoute(
|
|
||||||
id: "indicatorSettings"
|
|
||||||
) {
|
|
||||||
IndicatorSettingsView()
|
|
||||||
}
|
|
||||||
|
|
||||||
static func itemFilterDrawerSelector(selection: Binding<[ItemFilterType]>) -> NavigationRoute {
|
|
||||||
NavigationRoute(id: "itemFilterDrawerSelector") {
|
|
||||||
OrderedSectionSelectorView(selection: selection, sources: ItemFilterType.allCases)
|
|
||||||
.navigationTitle(L10n.filters)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func itemOverviewView(item: BaseItemDto) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "itemOverviewView",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
ItemOverviewView(item: item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func itemViewAttributes(selection: Binding<[ItemViewAttribute]>) -> NavigationRoute {
|
|
||||||
NavigationRoute(id: "itemViewAttributes") {
|
|
||||||
OrderedSectionSelectorView(selection: selection, sources: ItemViewAttribute.allCases)
|
|
||||||
.navigationTitle(L10n.mediaAttributes.localizedCapitalized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static let localSecurity = NavigationRoute(
|
|
||||||
id: "localSecurity"
|
|
||||||
) {
|
|
||||||
UserLocalSecurityView()
|
|
||||||
}
|
|
||||||
|
|
||||||
static let log = NavigationRoute(
|
|
||||||
id: "log"
|
|
||||||
) {
|
|
||||||
ConsoleView()
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
static let nativePlayerSettings = NavigationRoute(
|
|
||||||
id: "nativePlayerSettings"
|
|
||||||
) {
|
|
||||||
NativeVideoPlayerSettingsView()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
static let playbackQualitySettings = NavigationRoute(
|
|
||||||
id: "playbackQualitySettings"
|
|
||||||
) {
|
|
||||||
PlaybackQualitySettingsView()
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
static func resetUserPassword(userID: String) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "resetUserPassword",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
ResetUserPasswordView(userID: userID, requiresCurrentPassword: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
static func serverConnection(server: ServerState) -> NavigationRoute {
|
|
||||||
NavigationRoute(id: "serverConnection") {
|
|
||||||
EditServerView(server: server)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static let settings = NavigationRoute(
|
|
||||||
id: "settings",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
SettingsView()
|
|
||||||
}
|
|
||||||
|
|
||||||
static func userProfile(viewModel: SettingsViewModel) -> NavigationRoute {
|
|
||||||
NavigationRoute(id: "userProfile") {
|
|
||||||
UserProfileSettingsView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static let videoPlayerSettings = NavigationRoute(
|
|
||||||
id: "videoPlayerSettings"
|
|
||||||
) {
|
|
||||||
VideoPlayerSettingsView()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,78 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import JellyfinAPI
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
extension NavigationRoute {
|
|
||||||
|
|
||||||
static let connectToServer = NavigationRoute(
|
|
||||||
id: "connectToServer",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
ConnectToServerView()
|
|
||||||
}
|
|
||||||
|
|
||||||
static func quickConnect(quickConnect: QuickConnect) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "quickConnectView",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
QuickConnectView(quickConnect: quickConnect)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
static func userProfileImage(viewModel: UserProfileImageViewModel) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "userProfileImage",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
UserProfileImagePickerView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func userProfileImageCrop(viewModel: UserProfileImageViewModel, image: UIImage) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "cropImage",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
UserProfileImageCropView(
|
|
||||||
viewModel: viewModel,
|
|
||||||
image: image
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: rename to `localUserAccessPolicy`
|
|
||||||
static func userSecurity(pinHint: Binding<String>, accessPolicy: Binding<UserAccessPolicy>) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "userSecurity",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
LocalUserAccessPolicyView(
|
|
||||||
pinHint: pinHint,
|
|
||||||
accessPolicy: accessPolicy
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
static func userSignIn(server: ServerState) -> NavigationRoute {
|
|
||||||
NavigationRoute(
|
|
||||||
id: "userSignIn",
|
|
||||||
style: .sheet
|
|
||||||
) {
|
|
||||||
WithUserAuthentication {
|
|
||||||
WithQuickConnect {
|
|
||||||
UserSignInView(server: server)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,87 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct NavigationRoute: Identifiable, Hashable {
|
|
||||||
|
|
||||||
enum TransitionStyle: Hashable {
|
|
||||||
|
|
||||||
// TODO: sheet and fullscreen with `NavigationTransition`
|
|
||||||
case push(NavigationTransition)
|
|
||||||
case sheet
|
|
||||||
case fullscreen
|
|
||||||
}
|
|
||||||
|
|
||||||
enum TransitionType {
|
|
||||||
|
|
||||||
case automatic(TransitionStyle)
|
|
||||||
case withNamespace((Namespace.ID) -> TransitionStyle)
|
|
||||||
}
|
|
||||||
|
|
||||||
let id: String
|
|
||||||
|
|
||||||
private let content: AnyView
|
|
||||||
var transitionType: TransitionType
|
|
||||||
var namespace: Namespace.ID?
|
|
||||||
|
|
||||||
var transitionStyle: TransitionStyle {
|
|
||||||
switch transitionType {
|
|
||||||
case let .automatic(style):
|
|
||||||
return style
|
|
||||||
case let .withNamespace(builder):
|
|
||||||
if let namespace {
|
|
||||||
return builder(namespace)
|
|
||||||
} else {
|
|
||||||
return .push(.automatic)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init(
|
|
||||||
id: String,
|
|
||||||
style: TransitionStyle = .push(.automatic),
|
|
||||||
@ViewBuilder content: () -> some View
|
|
||||||
) {
|
|
||||||
self.id = id
|
|
||||||
self.transitionType = .automatic(style)
|
|
||||||
self.namespace = nil
|
|
||||||
self.content = AnyView(content())
|
|
||||||
}
|
|
||||||
|
|
||||||
init(
|
|
||||||
id: String,
|
|
||||||
withNamespace: @escaping (Namespace.ID) -> TransitionStyle,
|
|
||||||
@ViewBuilder content: () -> some View
|
|
||||||
) {
|
|
||||||
self.id = id
|
|
||||||
self.transitionType = .withNamespace(withNamespace)
|
|
||||||
self.namespace = nil
|
|
||||||
self.content = AnyView(content())
|
|
||||||
}
|
|
||||||
|
|
||||||
func hash(into hasher: inout Hasher) {
|
|
||||||
hasher.combine(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
|
||||||
lhs.id == rhs.id
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
var destination: some View {
|
|
||||||
if case let .push(style) = transitionStyle {
|
|
||||||
content
|
|
||||||
.backport
|
|
||||||
.navigationTransition(style)
|
|
||||||
} else {
|
|
||||||
content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,101 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
extension NavigationCoordinator {
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct Router {
|
|
||||||
|
|
||||||
let navigationCoordinator: NavigationCoordinator?
|
|
||||||
let rootCoordinator: RootCoordinator?
|
|
||||||
|
|
||||||
func route(
|
|
||||||
to route: NavigationRoute,
|
|
||||||
transition: NavigationRoute.TransitionType? = nil,
|
|
||||||
in namespace: Namespace.ID? = nil
|
|
||||||
) {
|
|
||||||
var route = route
|
|
||||||
route.namespace = namespace
|
|
||||||
route.transitionType = transition ?? route.transitionType
|
|
||||||
navigationCoordinator?.push(route)
|
|
||||||
}
|
|
||||||
|
|
||||||
func root(
|
|
||||||
_ root: RootItem
|
|
||||||
) {
|
|
||||||
rootCoordinator?.root(root)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@propertyWrapper
|
|
||||||
struct Router: DynamicProperty {
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct Wrapper {
|
|
||||||
let router: NavigationCoordinator.Router
|
|
||||||
let dismiss: DismissAction
|
|
||||||
|
|
||||||
func route(
|
|
||||||
to route: NavigationRoute,
|
|
||||||
in namespace: Namespace.ID? = nil
|
|
||||||
) {
|
|
||||||
router.route(
|
|
||||||
to: route,
|
|
||||||
transition: nil,
|
|
||||||
in: namespace
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func route(
|
|
||||||
to route: NavigationRoute,
|
|
||||||
style: NavigationRoute.TransitionStyle,
|
|
||||||
in namespace: Namespace.ID? = nil
|
|
||||||
) {
|
|
||||||
router.route(
|
|
||||||
to: route,
|
|
||||||
transition: .automatic(style),
|
|
||||||
in: namespace
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func route(
|
|
||||||
to route: NavigationRoute,
|
|
||||||
withNamespace: @escaping (Namespace.ID) -> NavigationRoute.TransitionStyle,
|
|
||||||
in namespace: Namespace.ID? = nil
|
|
||||||
) {
|
|
||||||
router.route(
|
|
||||||
to: route,
|
|
||||||
transition: .withNamespace(withNamespace),
|
|
||||||
in: namespace
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// `.dismiss` causes changes on disappear
|
|
||||||
@Environment(\.self)
|
|
||||||
private var environment
|
|
||||||
|
|
||||||
var wrappedValue: Wrapper {
|
|
||||||
.init(
|
|
||||||
router: environment.router,
|
|
||||||
dismiss: environment.dismiss
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension EnvironmentValues {
|
|
||||||
|
|
||||||
@Entry
|
|
||||||
var router: NavigationCoordinator.Router = .init(
|
|
||||||
navigationCoordinator: nil,
|
|
||||||
rootCoordinator: nil
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,56 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
import SwiftUI
|
|
||||||
import Transmission
|
|
||||||
|
|
||||||
// TODO: sometimes causes hangs?
|
|
||||||
|
|
||||||
struct WithTransitionReaderPublisher<Content: View>: View {
|
|
||||||
|
|
||||||
@StateObject
|
|
||||||
private var publishedBox: PublishedBox<LegacyEventPublisher<TransitionReaderProxy?>> = .init(initialValue: .init())
|
|
||||||
|
|
||||||
let content: Content
|
|
||||||
|
|
||||||
init(@ViewBuilder content: @escaping () -> Content) {
|
|
||||||
self.content = content()
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
content
|
|
||||||
.environment(\.transitionReader, publishedBox.value)
|
|
||||||
.background {
|
|
||||||
TransitionReader { proxy in
|
|
||||||
Color.clear
|
|
||||||
.onChange(of: proxy) { newValue in
|
|
||||||
publishedBox.value.send(newValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@propertyWrapper
|
|
||||||
struct TransitionReaderObserver: DynamicProperty {
|
|
||||||
|
|
||||||
@Environment(\.transitionReader)
|
|
||||||
private var publisher
|
|
||||||
|
|
||||||
var wrappedValue: LegacyEventPublisher<TransitionReaderProxy?> {
|
|
||||||
publisher
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension EnvironmentValues {
|
|
||||||
|
|
||||||
@Entry
|
|
||||||
var transitionReader: LegacyEventPublisher<TransitionReaderProxy?> = .init()
|
|
||||||
}
|
|
||||||
#endif
|
|
|
@ -1,86 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import Defaults
|
|
||||||
import Factory
|
|
||||||
import Logging
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class RootCoordinator: ObservableObject {
|
|
||||||
|
|
||||||
@Published
|
|
||||||
var root: RootItem = .appLoading
|
|
||||||
|
|
||||||
private let logger = Logger.swiftfin()
|
|
||||||
|
|
||||||
init() {
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
try await SwiftfinStore.setupDataStack()
|
|
||||||
|
|
||||||
if Container.shared.currentUserSession() != nil, !Defaults[.signOutOnClose] {
|
|
||||||
#if os(tvOS)
|
|
||||||
await MainActor.run {
|
|
||||||
root(.mainTab)
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
await MainActor.run {
|
|
||||||
root(.serverCheck)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
} else {
|
|
||||||
await MainActor.run {
|
|
||||||
root(.selectUser)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch {
|
|
||||||
await MainActor.run {
|
|
||||||
Notifications[.didFailMigration].post()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notification setup for state
|
|
||||||
Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn))
|
|
||||||
Notifications[.didSignOut].subscribe(self, selector: #selector(didSignOut))
|
|
||||||
Notifications[.didChangeCurrentServerURL].subscribe(self, selector: #selector(didChangeCurrentServerURL(_:)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func root(_ newRoot: RootItem) {
|
|
||||||
root = newRoot
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc
|
|
||||||
private func didSignIn() {
|
|
||||||
logger.info("Signed in")
|
|
||||||
|
|
||||||
#if os(tvOS)
|
|
||||||
root(.mainTab)
|
|
||||||
#else
|
|
||||||
root(.serverCheck)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc
|
|
||||||
private func didSignOut() {
|
|
||||||
logger.info("Signed out")
|
|
||||||
|
|
||||||
root(.selectUser)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc
|
|
||||||
func didChangeCurrentServerURL(_ notification: Notification) {
|
|
||||||
|
|
||||||
guard Container.shared.currentUserSession() != nil else { return }
|
|
||||||
|
|
||||||
Container.shared.currentUserSession.reset()
|
|
||||||
Notifications[.didSignIn].post()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,48 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct RootItem: Identifiable {
|
|
||||||
|
|
||||||
var id: String
|
|
||||||
let content: AnyView
|
|
||||||
|
|
||||||
init(
|
|
||||||
id: String,
|
|
||||||
@ViewBuilder content: () -> some View
|
|
||||||
) {
|
|
||||||
self.id = id
|
|
||||||
self.content = AnyView(content())
|
|
||||||
}
|
|
||||||
|
|
||||||
static let appLoading = RootItem(id: "appLoading") {
|
|
||||||
NavigationInjectionView(coordinator: .init()) {
|
|
||||||
AppLoadingView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static let mainTab = RootItem(id: "mainTab") {
|
|
||||||
MainTabView()
|
|
||||||
}
|
|
||||||
|
|
||||||
static let selectUser = RootItem(id: "selectUser") {
|
|
||||||
NavigationInjectionView(coordinator: .init()) {
|
|
||||||
SelectUserView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
static let serverCheck = RootItem(id: "serverCheck") {
|
|
||||||
NavigationInjectionView(coordinator: .init()) {
|
|
||||||
ServerCheckView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
|
@ -1,48 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import Transmission
|
|
||||||
|
|
||||||
// Status bar presentation needs to happen at this level
|
|
||||||
struct RootView: View {
|
|
||||||
|
|
||||||
@State
|
|
||||||
private var isStatusBarHidden: Bool = false
|
|
||||||
|
|
||||||
@StateObject
|
|
||||||
private var rootCoordinator: RootCoordinator = .init()
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
if rootCoordinator.root.id == RootItem.appLoading.id {
|
|
||||||
RootItem.appLoading.content
|
|
||||||
}
|
|
||||||
|
|
||||||
if rootCoordinator.root.id == RootItem.mainTab.id {
|
|
||||||
RootItem.mainTab.content
|
|
||||||
}
|
|
||||||
|
|
||||||
if rootCoordinator.root.id == RootItem.selectUser.id {
|
|
||||||
RootItem.selectUser.content
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
if rootCoordinator.root.id == RootItem.serverCheck.id {
|
|
||||||
RootItem.serverCheck.content
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
.animation(.linear(duration: 0.1), value: rootCoordinator.root.id)
|
|
||||||
.environmentObject(rootCoordinator)
|
|
||||||
.prefersStatusBarHidden(isStatusBarHidden)
|
|
||||||
.onPreferenceChange(IsStatusBarHiddenKey.self) { newValue in
|
|
||||||
isStatusBarHidden = newValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,52 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import JellyfinAPI
|
|
||||||
import Stinsen
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
final class SearchCoordinator: NavigationCoordinatable {
|
|
||||||
|
|
||||||
let stack = NavigationStack(initial: \SearchCoordinator.start)
|
|
||||||
|
|
||||||
@Root
|
|
||||||
var start = makeStart
|
|
||||||
#if os(tvOS)
|
|
||||||
@Route(.push)
|
|
||||||
var item = makeItem
|
|
||||||
@Route(.push)
|
|
||||||
var library = makeLibrary
|
|
||||||
#else
|
|
||||||
@Route(.push)
|
|
||||||
var item = makeItem
|
|
||||||
@Route(.push)
|
|
||||||
var library = makeLibrary
|
|
||||||
@Route(.modal)
|
|
||||||
var filter = makeFilter
|
|
||||||
#endif
|
|
||||||
|
|
||||||
func makeItem(item: BaseItemDto) -> ItemCoordinator {
|
|
||||||
ItemCoordinator(item: item)
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
|
|
||||||
LibraryCoordinator(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
#if !os(tvOS)
|
|
||||||
func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator<FilterCoordinator> {
|
|
||||||
NavigationViewCoordinator(FilterCoordinator(parameters: parameters))
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeStart() -> some View {
|
|
||||||
SearchView()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,52 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import JellyfinAPI
|
|
||||||
import Stinsen
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
final class SearchCoordinator: NavigationCoordinatable {
|
|
||||||
|
|
||||||
let stack = NavigationStack(initial: \SearchCoordinator.start)
|
|
||||||
|
|
||||||
@Root
|
|
||||||
var start = makeStart
|
|
||||||
#if os(tvOS)
|
|
||||||
@Route(.push)
|
|
||||||
var item = makeItem
|
|
||||||
@Route(.push)
|
|
||||||
var library = makeLibrary
|
|
||||||
#else
|
|
||||||
@Route(.push)
|
|
||||||
var item = makeItem
|
|
||||||
@Route(.push)
|
|
||||||
var library = makeLibrary
|
|
||||||
@Route(.modal)
|
|
||||||
var filter = makeFilter
|
|
||||||
#endif
|
|
||||||
|
|
||||||
func makeItem(item: BaseItemDto) -> ItemCoordinator {
|
|
||||||
ItemCoordinator(item: item)
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
|
|
||||||
LibraryCoordinator(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
#if !os(tvOS)
|
|
||||||
func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator<FilterCoordinator> {
|
|
||||||
NavigationViewCoordinator(FilterCoordinator(parameters: parameters))
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeStart() -> some View {
|
|
||||||
SearchView()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -18,9 +18,9 @@ final class SearchCoordinator: NavigationCoordinatable {
|
||||||
@Root
|
@Root
|
||||||
var start = makeStart
|
var start = makeStart
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
@Route(.push)
|
@Route(.modal)
|
||||||
var item = makeItem
|
var item = makeItem
|
||||||
@Route(.push)
|
@Route(.modal)
|
||||||
var library = makeLibrary
|
var library = makeLibrary
|
||||||
#else
|
#else
|
||||||
@Route(.push)
|
@Route(.push)
|
||||||
|
@ -31,6 +31,15 @@ final class SearchCoordinator: NavigationCoordinatable {
|
||||||
var filter = makeFilter
|
var filter = makeFilter
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
func makeItem(item: BaseItemDto) -> NavigationViewCoordinator<ItemCoordinator> {
|
||||||
|
NavigationViewCoordinator(ItemCoordinator(item: item))
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> NavigationViewCoordinator<LibraryCoordinator<BaseItemDto>> {
|
||||||
|
NavigationViewCoordinator(LibraryCoordinator(viewModel: viewModel))
|
||||||
|
}
|
||||||
|
#else
|
||||||
func makeItem(item: BaseItemDto) -> ItemCoordinator {
|
func makeItem(item: BaseItemDto) -> ItemCoordinator {
|
||||||
ItemCoordinator(item: item)
|
ItemCoordinator(item: item)
|
||||||
}
|
}
|
||||||
|
@ -39,7 +48,6 @@ final class SearchCoordinator: NavigationCoordinatable {
|
||||||
LibraryCoordinator(viewModel: viewModel)
|
LibraryCoordinator(viewModel: viewModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
#if !os(tvOS)
|
|
||||||
func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator<FilterCoordinator> {
|
func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator<FilterCoordinator> {
|
||||||
NavigationViewCoordinator(FilterCoordinator(parameters: parameters))
|
NavigationViewCoordinator(FilterCoordinator(parameters: parameters))
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,59 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Stinsen
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
final class SelectUserCoordinator: NavigationCoordinatable {
|
|
||||||
|
|
||||||
let stack = NavigationStack(initial: \SelectUserCoordinator.start)
|
|
||||||
|
|
||||||
@Root
|
|
||||||
var start = makeStart
|
|
||||||
|
|
||||||
@Route(.push)
|
|
||||||
var advancedSettings = makeAdvancedSettings
|
|
||||||
@Route(.push)
|
|
||||||
var connectToServer = makeConnectToServer
|
|
||||||
@Route(.push)
|
|
||||||
var editServer = makeEditServer
|
|
||||||
@Route(.push)
|
|
||||||
var userSignIn = makeUserSignIn
|
|
||||||
|
|
||||||
func makeAdvancedSettings() -> NavigationViewCoordinator<AppSettingsCoordinator> {
|
|
||||||
NavigationViewCoordinator(AppSettingsCoordinator())
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeConnectToServer() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
|
||||||
NavigationViewCoordinator {
|
|
||||||
ConnectToServerView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeEditServer(server: ServerState) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
|
||||||
NavigationViewCoordinator {
|
|
||||||
EditServerView(server: server)
|
|
||||||
.environment(\.isEditing, true)
|
|
||||||
#if os(iOS)
|
|
||||||
.navigationBarCloseButton {
|
|
||||||
self.popLast()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeUserSignIn(server: ServerState) -> NavigationViewCoordinator<UserSignInCoordinator> {
|
|
||||||
NavigationViewCoordinator(UserSignInCoordinator(server: server))
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeStart() -> some View {
|
|
||||||
SelectUserView()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,59 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Stinsen
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
final class SelectUserCoordinator: NavigationCoordinatable {
|
|
||||||
|
|
||||||
let stack = NavigationStack(initial: \SelectUserCoordinator.start)
|
|
||||||
|
|
||||||
@Root
|
|
||||||
var start = makeStart
|
|
||||||
|
|
||||||
@Route(.push)
|
|
||||||
var advancedSettings = makeAdvancedSettings
|
|
||||||
@Route(.push)
|
|
||||||
var connectToServer = makeConnectToServer
|
|
||||||
@Route(.push)
|
|
||||||
var editServer = makeEditServer
|
|
||||||
@Route(.push)
|
|
||||||
var userSignIn = makeUserSignIn
|
|
||||||
|
|
||||||
func makeAdvancedSettings() -> NavigationViewCoordinator<AppSettingsCoordinator> {
|
|
||||||
NavigationViewCoordinator(AppSettingsCoordinator())
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeConnectToServer() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
|
||||||
NavigationViewCoordinator {
|
|
||||||
ConnectToServerView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeEditServer(server: ServerState) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
|
||||||
NavigationViewCoordinator {
|
|
||||||
EditServerView(server: server)
|
|
||||||
.environment(\.isEditing, true)
|
|
||||||
#if os(iOS)
|
|
||||||
.navigationBarCloseButton {
|
|
||||||
self.popLast()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeUserSignIn(server: ServerState) -> NavigationViewCoordinator<UserSignInCoordinator> {
|
|
||||||
NavigationViewCoordinator(UserSignInCoordinator(server: server))
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeStart() -> some View {
|
|
||||||
SelectUserView()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -17,36 +17,23 @@ final class SelectUserCoordinator: NavigationCoordinatable {
|
||||||
@Root
|
@Root
|
||||||
var start = makeStart
|
var start = makeStart
|
||||||
|
|
||||||
@Route(.push)
|
@Route(.modal)
|
||||||
var advancedSettings = makeAdvancedSettings
|
var advancedSettings = makeAdvancedSettings
|
||||||
@Route(.push)
|
@Route(.modal)
|
||||||
var connectToServer = makeConnectToServer
|
var connectToServer = makeConnectToServer
|
||||||
@Route(.push)
|
@Route(.modal)
|
||||||
var connectToXtream = makeConnectToXtream
|
|
||||||
@Route(.push)
|
|
||||||
var dualServerConnect = makeDualServerConnect
|
|
||||||
@Route(.push)
|
|
||||||
var editServer = makeEditServer
|
var editServer = makeEditServer
|
||||||
@Route(.push)
|
@Route(.modal)
|
||||||
var userSignIn = makeUserSignIn
|
var userSignIn = makeUserSignIn
|
||||||
|
|
||||||
func makeAdvancedSettings() -> NavigationViewCoordinator<AppSettingsCoordinator> {
|
func makeAdvancedSettings() -> NavigationViewCoordinator<AppSettingsCoordinator> {
|
||||||
NavigationViewCoordinator(AppSettingsCoordinator())
|
NavigationViewCoordinator(AppSettingsCoordinator())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
func makeConnectToServer() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||||
func makeConnectToServer() -> some View {
|
NavigationViewCoordinator {
|
||||||
ConnectToServerView()
|
ConnectToServerView()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeConnectToXtream() -> some View {
|
|
||||||
ConnectToXtreamView()
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeDualServerConnect() -> some View {
|
|
||||||
DualServerConnectView()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeEditServer(server: ServerState) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
func makeEditServer(server: ServerState) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||||
|
|
|
@ -1,263 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import JellyfinAPI
|
|
||||||
import PulseUI
|
|
||||||
import Stinsen
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
final class SettingsCoordinator: NavigationCoordinatable {
|
|
||||||
let stack = NavigationStack(initial: \SettingsCoordinator.start)
|
|
||||||
|
|
||||||
@Root
|
|
||||||
var start = makeStart
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
@Route(.push)
|
|
||||||
var log = makeLog
|
|
||||||
@Route(.push)
|
|
||||||
var nativePlayerSettings = makeNativePlayerSettings
|
|
||||||
@Route(.push)
|
|
||||||
var playbackQualitySettings = makePlaybackQualitySettings
|
|
||||||
@Route(.push)
|
|
||||||
var quickConnect = makeQuickConnectAuthorize
|
|
||||||
@Route(.push)
|
|
||||||
var resetUserPassword = makeResetUserPassword
|
|
||||||
@Route(.push)
|
|
||||||
var localSecurity = makeLocalSecurity
|
|
||||||
@Route(.push)
|
|
||||||
var photoPicker = makePhotoPicker
|
|
||||||
@Route(.push)
|
|
||||||
var userProfile = makeUserProfileSettings
|
|
||||||
|
|
||||||
@Route(.push)
|
|
||||||
var customizeViewsSettings = makeCustomizeViewsSettings
|
|
||||||
@Route(.push)
|
|
||||||
var experimentalSettings = makeExperimentalSettings
|
|
||||||
@Route(.push)
|
|
||||||
var itemFilterDrawerSelector = makeItemFilterDrawerSelector
|
|
||||||
@Route(.push)
|
|
||||||
var indicatorSettings = makeIndicatorSettings
|
|
||||||
@Route(.push)
|
|
||||||
var itemViewAttributes = makeItemViewAttributes
|
|
||||||
@Route(.push)
|
|
||||||
var serverConnection = makeServerConnection
|
|
||||||
@Route(.push)
|
|
||||||
var videoPlayerSettings = makeVideoPlayerSettings
|
|
||||||
@Route(.push)
|
|
||||||
var customDeviceProfileSettings = makeCustomDeviceProfileSettings
|
|
||||||
@Route(.push)
|
|
||||||
var itemOverviewView = makeItemOverviewView
|
|
||||||
|
|
||||||
@Route(.push)
|
|
||||||
var editCustomDeviceProfile = makeEditCustomDeviceProfile
|
|
||||||
@Route(.push)
|
|
||||||
var createCustomDeviceProfile = makeCreateCustomDeviceProfile
|
|
||||||
|
|
||||||
@Route(.push)
|
|
||||||
var adminDashboard = makeAdminDashboard
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
@Route(.push)
|
|
||||||
var debugSettings = makeDebugSettings
|
|
||||||
#endif
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if os(tvOS)
|
|
||||||
@Route(.push)
|
|
||||||
var customizeViewsSettings = makeCustomizeViewsSettings
|
|
||||||
@Route(.push)
|
|
||||||
var experimentalSettings = makeExperimentalSettings
|
|
||||||
@Route(.push)
|
|
||||||
var log = makeLog
|
|
||||||
@Route(.push)
|
|
||||||
var serverDetail = makeServerDetail
|
|
||||||
@Route(.push)
|
|
||||||
var videoPlayerSettings = makeVideoPlayerSettings
|
|
||||||
@Route(.push)
|
|
||||||
var playbackQualitySettings = makePlaybackQualitySettings
|
|
||||||
@Route(.push)
|
|
||||||
var userProfile = makeUserProfileSettings
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
@ViewBuilder
|
|
||||||
func makeNativePlayerSettings() -> some View {
|
|
||||||
NativeVideoPlayerSettingsView()
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makePlaybackQualitySettings() -> some View {
|
|
||||||
PlaybackQualitySettingsView()
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeCustomDeviceProfileSettings() -> some View {
|
|
||||||
CustomDeviceProfileSettingsView()
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeEditCustomDeviceProfile(profile: Binding<CustomDeviceProfile>)
|
|
||||||
-> NavigationViewCoordinator<EditCustomDeviceProfileCoordinator>
|
|
||||||
{
|
|
||||||
NavigationViewCoordinator(EditCustomDeviceProfileCoordinator(profile: profile))
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeCreateCustomDeviceProfile() -> NavigationViewCoordinator<EditCustomDeviceProfileCoordinator> {
|
|
||||||
NavigationViewCoordinator(EditCustomDeviceProfileCoordinator())
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeQuickConnectAuthorize(user: UserDto) -> some View {
|
|
||||||
QuickConnectAuthorizeView(user: user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeResetUserPassword(userID: String) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
|
||||||
NavigationViewCoordinator {
|
|
||||||
ResetUserPasswordView(userID: userID, requiresCurrentPassword: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeLocalSecurity() -> some View {
|
|
||||||
UserLocalSecurityView()
|
|
||||||
}
|
|
||||||
|
|
||||||
func makePhotoPicker(viewModel: UserProfileImageViewModel) -> NavigationViewCoordinator<UserProfileImageCoordinator> {
|
|
||||||
NavigationViewCoordinator(UserProfileImageCoordinator(viewModel: viewModel))
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeUserProfileSettings(viewModel: SettingsViewModel) -> some View {
|
|
||||||
UserProfileSettingsView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeCustomizeViewsSettings() -> some View {
|
|
||||||
CustomizeViewsSettings()
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeExperimentalSettings() -> some View {
|
|
||||||
ExperimentalSettingsView()
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeIndicatorSettings() -> some View {
|
|
||||||
IndicatorSettingsView()
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeItemViewAttributes(selection: Binding<[ItemViewAttribute]>) -> some View {
|
|
||||||
OrderedSectionSelectorView(selection: selection, sources: ItemViewAttribute.allCases)
|
|
||||||
.navigationTitle(L10n.mediaAttributes.localizedCapitalized)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeServerConnection(server: ServerState) -> some View {
|
|
||||||
EditServerView(server: server)
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeItemOverviewView(item: BaseItemDto) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
|
||||||
NavigationViewCoordinator {
|
|
||||||
ItemOverviewView(item: item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeItemFilterDrawerSelector(selection: Binding<[ItemFilterType]>) -> some View {
|
|
||||||
OrderedSectionSelectorView(selection: selection, sources: ItemFilterType.allCases)
|
|
||||||
.navigationTitle(L10n.filters)
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeVideoPlayerSettings() -> VideoPlayerSettingsCoordinator {
|
|
||||||
VideoPlayerSettingsCoordinator()
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeAdminDashboard() -> some View {
|
|
||||||
AdminDashboardCoordinator().view()
|
|
||||||
}
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
@ViewBuilder
|
|
||||||
func makeDebugSettings() -> some View {
|
|
||||||
DebugSettingsView()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if os(tvOS)
|
|
||||||
|
|
||||||
// MARK: - User Profile View
|
|
||||||
|
|
||||||
func makeUserProfileSettings(viewModel: SettingsViewModel) -> NavigationViewCoordinator<UserProfileSettingsCoordinator> {
|
|
||||||
NavigationViewCoordinator(
|
|
||||||
UserProfileSettingsCoordinator(viewModel: viewModel)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Customize Settings View
|
|
||||||
|
|
||||||
func makeCustomizeViewsSettings() -> NavigationViewCoordinator<CustomizeSettingsCoordinator> {
|
|
||||||
NavigationViewCoordinator(
|
|
||||||
CustomizeSettingsCoordinator()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Experimental Settings View
|
|
||||||
|
|
||||||
func makeExperimentalSettings() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
|
||||||
NavigationViewCoordinator(
|
|
||||||
BasicNavigationViewCoordinator {
|
|
||||||
ExperimentalSettingsView()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Poster Indicator Settings View
|
|
||||||
|
|
||||||
func makeIndicatorSettings() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
|
||||||
NavigationViewCoordinator {
|
|
||||||
IndicatorSettingsView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Server Settings View
|
|
||||||
|
|
||||||
func makeServerDetail(server: ServerState) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
|
||||||
NavigationViewCoordinator {
|
|
||||||
EditServerView(server: server)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Video Player Settings View
|
|
||||||
|
|
||||||
func makeVideoPlayerSettings() -> NavigationViewCoordinator<VideoPlayerSettingsCoordinator> {
|
|
||||||
NavigationViewCoordinator(
|
|
||||||
VideoPlayerSettingsCoordinator()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Playback Settings View
|
|
||||||
|
|
||||||
func makePlaybackQualitySettings() -> NavigationViewCoordinator<PlaybackQualitySettingsCoordinator> {
|
|
||||||
NavigationViewCoordinator(
|
|
||||||
PlaybackQualitySettingsCoordinator()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeLog() -> some View {
|
|
||||||
ConsoleView()
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeStart() -> some View {
|
|
||||||
SettingsView()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,263 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import JellyfinAPI
|
|
||||||
import PulseUI
|
|
||||||
import Stinsen
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
final class SettingsCoordinator: NavigationCoordinatable {
|
|
||||||
let stack = NavigationStack(initial: \SettingsCoordinator.start)
|
|
||||||
|
|
||||||
@Root
|
|
||||||
var start = makeStart
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
@Route(.push)
|
|
||||||
var log = makeLog
|
|
||||||
@Route(.push)
|
|
||||||
var nativePlayerSettings = makeNativePlayerSettings
|
|
||||||
@Route(.push)
|
|
||||||
var playbackQualitySettings = makePlaybackQualitySettings
|
|
||||||
@Route(.push)
|
|
||||||
var quickConnect = makeQuickConnectAuthorize
|
|
||||||
@Route(.push)
|
|
||||||
var resetUserPassword = makeResetUserPassword
|
|
||||||
@Route(.push)
|
|
||||||
var localSecurity = makeLocalSecurity
|
|
||||||
@Route(.push)
|
|
||||||
var photoPicker = makePhotoPicker
|
|
||||||
@Route(.push)
|
|
||||||
var userProfile = makeUserProfileSettings
|
|
||||||
|
|
||||||
@Route(.push)
|
|
||||||
var customizeViewsSettings = makeCustomizeViewsSettings
|
|
||||||
@Route(.push)
|
|
||||||
var experimentalSettings = makeExperimentalSettings
|
|
||||||
@Route(.push)
|
|
||||||
var itemFilterDrawerSelector = makeItemFilterDrawerSelector
|
|
||||||
@Route(.push)
|
|
||||||
var indicatorSettings = makeIndicatorSettings
|
|
||||||
@Route(.push)
|
|
||||||
var itemViewAttributes = makeItemViewAttributes
|
|
||||||
@Route(.push)
|
|
||||||
var serverConnection = makeServerConnection
|
|
||||||
@Route(.push)
|
|
||||||
var videoPlayerSettings = makeVideoPlayerSettings
|
|
||||||
@Route(.push)
|
|
||||||
var customDeviceProfileSettings = makeCustomDeviceProfileSettings
|
|
||||||
@Route(.push)
|
|
||||||
var itemOverviewView = makeItemOverviewView
|
|
||||||
|
|
||||||
@Route(.push)
|
|
||||||
var editCustomDeviceProfile = makeEditCustomDeviceProfile
|
|
||||||
@Route(.push)
|
|
||||||
var createCustomDeviceProfile = makeCreateCustomDeviceProfile
|
|
||||||
|
|
||||||
@Route(.push)
|
|
||||||
var adminDashboard = makeAdminDashboard
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
@Route(.push)
|
|
||||||
var debugSettings = makeDebugSettings
|
|
||||||
#endif
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if os(tvOS)
|
|
||||||
@Route(.push)
|
|
||||||
var customizeViewsSettings = makeCustomizeViewsSettings
|
|
||||||
@Route(.push)
|
|
||||||
var experimentalSettings = makeExperimentalSettings
|
|
||||||
@Route(.push)
|
|
||||||
var log = makeLog
|
|
||||||
@Route(.push)
|
|
||||||
var serverDetail = makeServerDetail
|
|
||||||
@Route(.push)
|
|
||||||
var videoPlayerSettings = makeVideoPlayerSettings
|
|
||||||
@Route(.push)
|
|
||||||
var playbackQualitySettings = makePlaybackQualitySettings
|
|
||||||
@Route(.push)
|
|
||||||
var userProfile = makeUserProfileSettings
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
@ViewBuilder
|
|
||||||
func makeNativePlayerSettings() -> some View {
|
|
||||||
NativeVideoPlayerSettingsView()
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makePlaybackQualitySettings() -> some View {
|
|
||||||
PlaybackQualitySettingsView()
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeCustomDeviceProfileSettings() -> some View {
|
|
||||||
CustomDeviceProfileSettingsView()
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeEditCustomDeviceProfile(profile: Binding<CustomDeviceProfile>)
|
|
||||||
-> NavigationViewCoordinator<EditCustomDeviceProfileCoordinator>
|
|
||||||
{
|
|
||||||
NavigationViewCoordinator(EditCustomDeviceProfileCoordinator(profile: profile))
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeCreateCustomDeviceProfile() -> NavigationViewCoordinator<EditCustomDeviceProfileCoordinator> {
|
|
||||||
NavigationViewCoordinator(EditCustomDeviceProfileCoordinator())
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeQuickConnectAuthorize(user: UserDto) -> some View {
|
|
||||||
QuickConnectAuthorizeView(user: user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeResetUserPassword(userID: String) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
|
||||||
NavigationViewCoordinator {
|
|
||||||
ResetUserPasswordView(userID: userID, requiresCurrentPassword: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeLocalSecurity() -> some View {
|
|
||||||
UserLocalSecurityView()
|
|
||||||
}
|
|
||||||
|
|
||||||
func makePhotoPicker(viewModel: UserProfileImageViewModel) -> NavigationViewCoordinator<UserProfileImageCoordinator> {
|
|
||||||
NavigationViewCoordinator(UserProfileImageCoordinator(viewModel: viewModel))
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeUserProfileSettings(viewModel: SettingsViewModel) -> some View {
|
|
||||||
UserProfileSettingsView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeCustomizeViewsSettings() -> some View {
|
|
||||||
CustomizeViewsSettings()
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeExperimentalSettings() -> some View {
|
|
||||||
ExperimentalSettingsView()
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeIndicatorSettings() -> some View {
|
|
||||||
IndicatorSettingsView()
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeItemViewAttributes(selection: Binding<[ItemViewAttribute]>) -> some View {
|
|
||||||
OrderedSectionSelectorView(selection: selection, sources: ItemViewAttribute.allCases)
|
|
||||||
.navigationTitle(L10n.mediaAttributes.localizedCapitalized)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeServerConnection(server: ServerState) -> some View {
|
|
||||||
EditServerView(server: server)
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeItemOverviewView(item: BaseItemDto) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
|
||||||
NavigationViewCoordinator {
|
|
||||||
ItemOverviewView(item: item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeItemFilterDrawerSelector(selection: Binding<[ItemFilterType]>) -> some View {
|
|
||||||
OrderedSectionSelectorView(selection: selection, sources: ItemFilterType.allCases)
|
|
||||||
.navigationTitle(L10n.filters)
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeVideoPlayerSettings() -> VideoPlayerSettingsCoordinator {
|
|
||||||
VideoPlayerSettingsCoordinator()
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeAdminDashboard() -> some View {
|
|
||||||
AdminDashboardCoordinator().view()
|
|
||||||
}
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
@ViewBuilder
|
|
||||||
func makeDebugSettings() -> some View {
|
|
||||||
DebugSettingsView()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if os(tvOS)
|
|
||||||
|
|
||||||
// MARK: - User Profile View
|
|
||||||
|
|
||||||
func makeUserProfileSettings(viewModel: SettingsViewModel) -> NavigationViewCoordinator<UserProfileSettingsCoordinator> {
|
|
||||||
NavigationViewCoordinator(
|
|
||||||
UserProfileSettingsCoordinator(viewModel: viewModel)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Customize Settings View
|
|
||||||
|
|
||||||
func makeCustomizeViewsSettings() -> NavigationViewCoordinator<CustomizeSettingsCoordinator> {
|
|
||||||
NavigationViewCoordinator(
|
|
||||||
CustomizeSettingsCoordinator()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Experimental Settings View
|
|
||||||
|
|
||||||
func makeExperimentalSettings() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
|
||||||
NavigationViewCoordinator(
|
|
||||||
BasicNavigationViewCoordinator {
|
|
||||||
ExperimentalSettingsView()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Poster Indicator Settings View
|
|
||||||
|
|
||||||
func makeIndicatorSettings() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
|
||||||
NavigationViewCoordinator {
|
|
||||||
IndicatorSettingsView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Server Settings View
|
|
||||||
|
|
||||||
func makeServerDetail(server: ServerState) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
|
||||||
NavigationViewCoordinator {
|
|
||||||
EditServerView(server: server)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Video Player Settings View
|
|
||||||
|
|
||||||
func makeVideoPlayerSettings() -> NavigationViewCoordinator<VideoPlayerSettingsCoordinator> {
|
|
||||||
NavigationViewCoordinator(
|
|
||||||
VideoPlayerSettingsCoordinator()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Playback Settings View
|
|
||||||
|
|
||||||
func makePlaybackQualitySettings() -> NavigationViewCoordinator<PlaybackQualitySettingsCoordinator> {
|
|
||||||
NavigationViewCoordinator(
|
|
||||||
PlaybackQualitySettingsCoordinator()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeLog() -> some View {
|
|
||||||
ConsoleView()
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeStart() -> some View {
|
|
||||||
SettingsView()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -26,11 +26,11 @@ final class SettingsCoordinator: NavigationCoordinatable {
|
||||||
var playbackQualitySettings = makePlaybackQualitySettings
|
var playbackQualitySettings = makePlaybackQualitySettings
|
||||||
@Route(.push)
|
@Route(.push)
|
||||||
var quickConnect = makeQuickConnectAuthorize
|
var quickConnect = makeQuickConnectAuthorize
|
||||||
@Route(.push)
|
@Route(.modal)
|
||||||
var resetUserPassword = makeResetUserPassword
|
var resetUserPassword = makeResetUserPassword
|
||||||
@Route(.push)
|
@Route(.push)
|
||||||
var localSecurity = makeLocalSecurity
|
var localSecurity = makeLocalSecurity
|
||||||
@Route(.push)
|
@Route(.modal)
|
||||||
var photoPicker = makePhotoPicker
|
var photoPicker = makePhotoPicker
|
||||||
@Route(.push)
|
@Route(.push)
|
||||||
var userProfile = makeUserProfileSettings
|
var userProfile = makeUserProfileSettings
|
||||||
|
@ -51,12 +51,12 @@ final class SettingsCoordinator: NavigationCoordinatable {
|
||||||
var videoPlayerSettings = makeVideoPlayerSettings
|
var videoPlayerSettings = makeVideoPlayerSettings
|
||||||
@Route(.push)
|
@Route(.push)
|
||||||
var customDeviceProfileSettings = makeCustomDeviceProfileSettings
|
var customDeviceProfileSettings = makeCustomDeviceProfileSettings
|
||||||
@Route(.push)
|
@Route(.modal)
|
||||||
var itemOverviewView = makeItemOverviewView
|
var itemOverviewView = makeItemOverviewView
|
||||||
|
|
||||||
@Route(.push)
|
@Route(.modal)
|
||||||
var editCustomDeviceProfile = makeEditCustomDeviceProfile
|
var editCustomDeviceProfile = makeEditCustomDeviceProfile
|
||||||
@Route(.push)
|
@Route(.modal)
|
||||||
var createCustomDeviceProfile = makeCreateCustomDeviceProfile
|
var createCustomDeviceProfile = makeCreateCustomDeviceProfile
|
||||||
|
|
||||||
@Route(.push)
|
@Route(.push)
|
||||||
|
@ -69,19 +69,19 @@ final class SettingsCoordinator: NavigationCoordinatable {
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
@Route(.push)
|
@Route(.modal)
|
||||||
var customizeViewsSettings = makeCustomizeViewsSettings
|
var customizeViewsSettings = makeCustomizeViewsSettings
|
||||||
@Route(.push)
|
@Route(.modal)
|
||||||
var experimentalSettings = makeExperimentalSettings
|
var experimentalSettings = makeExperimentalSettings
|
||||||
@Route(.push)
|
@Route(.modal)
|
||||||
var log = makeLog
|
var log = makeLog
|
||||||
@Route(.push)
|
@Route(.modal)
|
||||||
var serverDetail = makeServerDetail
|
var serverDetail = makeServerDetail
|
||||||
@Route(.push)
|
@Route(.modal)
|
||||||
var videoPlayerSettings = makeVideoPlayerSettings
|
var videoPlayerSettings = makeVideoPlayerSettings
|
||||||
@Route(.push)
|
@Route(.modal)
|
||||||
var playbackQualitySettings = makePlaybackQualitySettings
|
var playbackQualitySettings = makePlaybackQualitySettings
|
||||||
@Route(.push)
|
@Route(.modal)
|
||||||
var userProfile = makeUserProfileSettings
|
var userProfile = makeUserProfileSettings
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
|
|
@ -1,69 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import Factory
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
// TODO: move popup to router
|
|
||||||
// - or, make tab view environment object
|
|
||||||
|
|
||||||
// TODO: fix weird tvOS icon rendering
|
|
||||||
struct MainTabView: View {
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
@StateObject
|
|
||||||
private var tabCoordinator = TabCoordinator {
|
|
||||||
TabItem.home
|
|
||||||
TabItem.search
|
|
||||||
TabItem.media
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
@StateObject
|
|
||||||
private var tabCoordinator = TabCoordinator {
|
|
||||||
TabItem.home
|
|
||||||
TabItem.library(
|
|
||||||
title: L10n.tvShows,
|
|
||||||
systemName: "tv",
|
|
||||||
filters: .init(itemTypes: [.series])
|
|
||||||
)
|
|
||||||
TabItem.library(
|
|
||||||
title: L10n.movies,
|
|
||||||
systemName: "film",
|
|
||||||
filters: .init(itemTypes: [.movie])
|
|
||||||
)
|
|
||||||
TabItem.search
|
|
||||||
TabItem.media
|
|
||||||
TabItem.settings
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
var body: some View {
|
|
||||||
TabView(selection: $tabCoordinator.selectedTabID) {
|
|
||||||
ForEach(tabCoordinator.tabs, id: \.item.id) { tab in
|
|
||||||
NavigationInjectionView(
|
|
||||||
coordinator: tab.coordinator
|
|
||||||
) {
|
|
||||||
tab.item.content
|
|
||||||
}
|
|
||||||
.environmentObject(tabCoordinator)
|
|
||||||
.environment(\.tabItemSelected, tab.publisher)
|
|
||||||
.tabItem {
|
|
||||||
Label(
|
|
||||||
tab.item.title,
|
|
||||||
systemImage: tab.item.systemImage
|
|
||||||
)
|
|
||||||
.labelStyle(tab.item.labelStyle)
|
|
||||||
.symbolRenderingMode(.monochrome)
|
|
||||||
.eraseToAnyView()
|
|
||||||
}
|
|
||||||
.tag(tab.item.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,50 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class TabCoordinator: ObservableObject {
|
|
||||||
|
|
||||||
struct SelectedEvent {
|
|
||||||
let isRoot: Bool
|
|
||||||
let isRepeat: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
typealias TabData = (
|
|
||||||
item: TabItem,
|
|
||||||
coordinator: NavigationCoordinator,
|
|
||||||
publisher: TabItemSelectedPublisher
|
|
||||||
)
|
|
||||||
|
|
||||||
@Published
|
|
||||||
var selectedTabID: String! = nil {
|
|
||||||
didSet {
|
|
||||||
guard let tab = tabs.first(property: \.item.id, equalTo: selectedTabID) else { return }
|
|
||||||
|
|
||||||
tab.publisher.send(
|
|
||||||
.init(
|
|
||||||
isRoot: tab.coordinator.path.isEmpty,
|
|
||||||
isRepeat: oldValue == selectedTabID
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Published
|
|
||||||
var tabs: [TabData] = []
|
|
||||||
|
|
||||||
init(@ArrayBuilder<TabItem> tabs: () -> [TabItem]) {
|
|
||||||
let tabs = tabs()
|
|
||||||
self.tabs = tabs.map { tab in
|
|
||||||
let coordinator = NavigationCoordinator()
|
|
||||||
let event = TabItemSelectedPublisher()
|
|
||||||
return (tab, coordinator, event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,95 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
// TODO: selected icon
|
|
||||||
struct TabItem: Identifiable, Hashable {
|
|
||||||
|
|
||||||
let content: AnyView
|
|
||||||
let id: String
|
|
||||||
let title: String
|
|
||||||
let systemImage: String
|
|
||||||
let labelStyle: any LabelStyle
|
|
||||||
|
|
||||||
init(
|
|
||||||
id: String,
|
|
||||||
title: String,
|
|
||||||
systemImage: String,
|
|
||||||
labelStyle: some LabelStyle = .titleAndIcon,
|
|
||||||
@ViewBuilder content: () -> some View
|
|
||||||
) {
|
|
||||||
self.content = AnyView(content())
|
|
||||||
self.id = id
|
|
||||||
self.title = title
|
|
||||||
self.systemImage = systemImage
|
|
||||||
self.labelStyle = labelStyle
|
|
||||||
}
|
|
||||||
|
|
||||||
func hash(into hasher: inout Hasher) {
|
|
||||||
hasher.combine(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
|
||||||
lhs.id == rhs.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension TabItem {
|
|
||||||
|
|
||||||
static let home = TabItem(
|
|
||||||
id: "home",
|
|
||||||
title: L10n.home,
|
|
||||||
systemImage: "house"
|
|
||||||
) {
|
|
||||||
HomeView()
|
|
||||||
}
|
|
||||||
|
|
||||||
static func library(
|
|
||||||
title: String,
|
|
||||||
systemName: String,
|
|
||||||
filters: ItemFilterCollection
|
|
||||||
) -> TabItem {
|
|
||||||
TabItem(
|
|
||||||
id: "library-\(UUID().uuidString)",
|
|
||||||
title: title,
|
|
||||||
systemImage: systemName
|
|
||||||
) {
|
|
||||||
let viewModel = ItemLibraryViewModel(
|
|
||||||
filters: filters
|
|
||||||
)
|
|
||||||
|
|
||||||
PagingLibraryView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static let media = TabItem(
|
|
||||||
id: "media",
|
|
||||||
title: L10n.media,
|
|
||||||
systemImage: "rectangle.stack.fill"
|
|
||||||
) {
|
|
||||||
MediaView()
|
|
||||||
}
|
|
||||||
|
|
||||||
static let search = TabItem(
|
|
||||||
id: "search",
|
|
||||||
title: L10n.search,
|
|
||||||
systemImage: "magnifyingglass"
|
|
||||||
) {
|
|
||||||
SearchView()
|
|
||||||
}
|
|
||||||
|
|
||||||
static let settings = TabItem(
|
|
||||||
id: "settings",
|
|
||||||
title: L10n.settings,
|
|
||||||
systemImage: "gearshape",
|
|
||||||
labelStyle: .iconOnly
|
|
||||||
) {
|
|
||||||
SettingsView()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,31 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
extension TabCoordinator {
|
|
||||||
|
|
||||||
typealias TabItemSelectedPublisher = LegacyEventPublisher<TabCoordinator.SelectedEvent>
|
|
||||||
}
|
|
||||||
|
|
||||||
@propertyWrapper
|
|
||||||
struct TabItemSelected: DynamicProperty {
|
|
||||||
|
|
||||||
@Environment(\.tabItemSelected)
|
|
||||||
private var publisher
|
|
||||||
|
|
||||||
var wrappedValue: TabCoordinator.TabItemSelectedPublisher {
|
|
||||||
publisher
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension EnvironmentValues {
|
|
||||||
|
|
||||||
@Entry
|
|
||||||
var tabItemSelected: TabCoordinator.TabItemSelectedPublisher = .init()
|
|
||||||
}
|
|
|
@ -1,61 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import JellyfinAPI
|
|
||||||
import Stinsen
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
final class UserSignInCoordinator: NavigationCoordinatable {
|
|
||||||
|
|
||||||
struct SecurityParameters {
|
|
||||||
let pinHint: Binding<String>
|
|
||||||
let accessPolicy: Binding<UserAccessPolicy>
|
|
||||||
}
|
|
||||||
|
|
||||||
let stack = NavigationStack(initial: \UserSignInCoordinator.start)
|
|
||||||
|
|
||||||
@Root
|
|
||||||
var start = makeStart
|
|
||||||
|
|
||||||
@Route(.push)
|
|
||||||
var quickConnect = makeQuickConnect
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
@Route(.push)
|
|
||||||
var security = makeSecurity
|
|
||||||
#endif
|
|
||||||
|
|
||||||
private let server: ServerState
|
|
||||||
|
|
||||||
init(server: ServerState) {
|
|
||||||
self.server = server
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeQuickConnect(quickConnect: QuickConnect) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
|
||||||
NavigationViewCoordinator {
|
|
||||||
QuickConnectView(quickConnect: quickConnect)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
func makeSecurity(parameters: SecurityParameters) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
|
||||||
NavigationViewCoordinator {
|
|
||||||
UserSignInView.SecurityView(
|
|
||||||
pinHint: parameters.pinHint,
|
|
||||||
accessPolicy: parameters.accessPolicy
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeStart() -> some View {
|
|
||||||
UserSignInView(server: server)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,61 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import JellyfinAPI
|
|
||||||
import Stinsen
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
final class UserSignInCoordinator: NavigationCoordinatable {
|
|
||||||
|
|
||||||
struct SecurityParameters {
|
|
||||||
let pinHint: Binding<String>
|
|
||||||
let accessPolicy: Binding<UserAccessPolicy>
|
|
||||||
}
|
|
||||||
|
|
||||||
let stack = NavigationStack(initial: \UserSignInCoordinator.start)
|
|
||||||
|
|
||||||
@Root
|
|
||||||
var start = makeStart
|
|
||||||
|
|
||||||
@Route(.push)
|
|
||||||
var quickConnect = makeQuickConnect
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
@Route(.push)
|
|
||||||
var security = makeSecurity
|
|
||||||
#endif
|
|
||||||
|
|
||||||
private let server: ServerState
|
|
||||||
|
|
||||||
init(server: ServerState) {
|
|
||||||
self.server = server
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeQuickConnect(quickConnect: QuickConnect) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
|
||||||
NavigationViewCoordinator {
|
|
||||||
QuickConnectView(quickConnect: quickConnect)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
func makeSecurity(parameters: SecurityParameters) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
|
||||||
NavigationViewCoordinator {
|
|
||||||
UserSignInView.SecurityView(
|
|
||||||
pinHint: parameters.pinHint,
|
|
||||||
accessPolicy: parameters.accessPolicy
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func makeStart() -> some View {
|
|
||||||
UserSignInView(server: server)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -23,11 +23,11 @@ final class UserSignInCoordinator: NavigationCoordinatable {
|
||||||
@Root
|
@Root
|
||||||
var start = makeStart
|
var start = makeStart
|
||||||
|
|
||||||
@Route(.push)
|
@Route(.modal)
|
||||||
var quickConnect = makeQuickConnect
|
var quickConnect = makeQuickConnect
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@Route(.push)
|
@Route(.modal)
|
||||||
var security = makeSecurity
|
var security = makeSecurity
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
|
|
@ -1,22 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import BlurHashKit
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
extension BlurHash {
|
|
||||||
|
|
||||||
var averageLinearColor: Color {
|
|
||||||
let color = averageLinearRGB
|
|
||||||
return Color(
|
|
||||||
red: Double(color.0),
|
|
||||||
green: Double(color.1),
|
|
||||||
blue: Double(color.2)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,33 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@propertyWrapper
|
|
||||||
struct BoxedPublished<Value>: DynamicProperty {
|
|
||||||
|
|
||||||
@StateObject
|
|
||||||
var storage: PublishedBox<Value>
|
|
||||||
|
|
||||||
init(wrappedValue: Value) {
|
|
||||||
self._storage = StateObject(wrappedValue: PublishedBox(initialValue: wrappedValue))
|
|
||||||
}
|
|
||||||
|
|
||||||
var wrappedValue: Value {
|
|
||||||
get { storage.value }
|
|
||||||
nonmutating set { storage.value = newValue }
|
|
||||||
}
|
|
||||||
|
|
||||||
var projectedValue: Published<Value>.Publisher {
|
|
||||||
storage.$value
|
|
||||||
}
|
|
||||||
|
|
||||||
var box: PublishedBox<Value> {
|
|
||||||
storage
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,32 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
func abs(_ d: Duration) -> Duration {
|
|
||||||
d < .zero ? (.zero - d) : d
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Duration {
|
|
||||||
|
|
||||||
/// Represent Jellyfin ticks as a Duration
|
|
||||||
static func ticks(_ ticks: Int) -> Duration {
|
|
||||||
Duration.microseconds(Int64(ticks) / 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
var microseconds: Int64 {
|
|
||||||
(components.attoseconds / 1_000_000_000_000) + components.seconds * 1_000_000
|
|
||||||
}
|
|
||||||
|
|
||||||
var seconds: Double {
|
|
||||||
Double(components.seconds) + Double(components.attoseconds) * 1e-18
|
|
||||||
}
|
|
||||||
|
|
||||||
var ticks: Int {
|
|
||||||
Int(microseconds * 10)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,15 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
extension FocusedValues {
|
|
||||||
|
|
||||||
@Entry
|
|
||||||
var focusedPoster: AnyPoster?
|
|
||||||
}
|
|
|
@ -1,14 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
extension AnyView: PlatformView {
|
|
||||||
var iOSView: some View { self }
|
|
||||||
var tvOSView: some View { self }
|
|
||||||
}
|
|
|
@ -105,13 +105,6 @@ extension BaseItemDto {
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("liveVideoPlayerViewModel matchingMediaSource being returned")
|
logger.debug("liveVideoPlayerViewModel matchingMediaSource being returned")
|
||||||
logger.debug(" TranscodingURL: \(matchingMediaSource.transcodingURL ?? "nil")")
|
|
||||||
logger.debug(" Path: \(matchingMediaSource.path ?? "nil")")
|
|
||||||
logger.debug(" Container: \(matchingMediaSource.container ?? "nil")")
|
|
||||||
logger.debug(" SupportsDirectPlay: \(matchingMediaSource.isSupportsDirectPlay ?? false)")
|
|
||||||
logger.debug(" PlaySessionID: \(response.value.playSessionID ?? "nil")")
|
|
||||||
logger.debug(" LiveStreamID: \(matchingMediaSource.liveStreamID ?? "nil")")
|
|
||||||
logger.debug(" OpenToken: \(matchingMediaSource.openToken ?? "nil")")
|
|
||||||
return try matchingMediaSource.liveVideoPlayerViewModel(
|
return try matchingMediaSource.liveVideoPlayerViewModel(
|
||||||
with: self,
|
with: self,
|
||||||
playSessionID: response.value.playSessionID!
|
playSessionID: response.value.playSessionID!
|
||||||
|
|
|
@ -1,34 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import JellyfinAPI
|
|
||||||
|
|
||||||
extension CountryInfo: Displayable {
|
|
||||||
|
|
||||||
var displayTitle: String {
|
|
||||||
if let twoLetterISORegionName, let name = Locale.current.localizedString(forRegionCode: twoLetterISORegionName) {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|
||||||
if let threeLetterISORegionName, let name = Locale.current.localizedString(forRegionCode: threeLetterISORegionName) {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|
||||||
return displayName ?? L10n.unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension CountryInfo {
|
|
||||||
|
|
||||||
static var none: CountryInfo {
|
|
||||||
CountryInfo(
|
|
||||||
displayName: L10n.none
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,36 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import JellyfinAPI
|
|
||||||
|
|
||||||
extension CultureDto: Displayable {
|
|
||||||
|
|
||||||
var displayTitle: String {
|
|
||||||
if let twoLetterISOLanguageName,
|
|
||||||
let name = Locale.current.localizedString(forLanguageCode: twoLetterISOLanguageName)
|
|
||||||
{
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|
||||||
if let threeLetterISOLanguageNames, let displayName = threeLetterISOLanguageNames
|
|
||||||
.compactMap({ Locale.current.localizedString(forLanguageCode: $0) })
|
|
||||||
.first
|
|
||||||
{
|
|
||||||
return displayName
|
|
||||||
}
|
|
||||||
|
|
||||||
return displayName ?? L10n.unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
static var none: CultureDto {
|
|
||||||
CultureDto(
|
|
||||||
displayName: L10n.none
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -72,91 +72,34 @@ extension MediaSourceInfo {
|
||||||
let playbackURL: URL
|
let playbackURL: URL
|
||||||
let playMethod: PlayMethod
|
let playMethod: PlayMethod
|
||||||
|
|
||||||
print("🎬 liveVideoPlayerViewModel: Starting for item \(item.displayTitle)")
|
if let transcodingURL {
|
||||||
print("🎬 Server URL: \(userSession.server.currentURL)")
|
guard let fullTranscodeURL = URL(string: transcodingURL, relativeTo: userSession.server.currentURL)
|
||||||
print("🎬 TranscodingURL: \(transcodingURL ?? "nil")")
|
else { throw JellyfinAPIError("Unable to construct transcoded url") }
|
||||||
print("🎬 Path: \(self.path ?? "nil")")
|
|
||||||
print("🎬 SupportsDirectPlay: \(self.isSupportsDirectPlay ?? false)")
|
|
||||||
print("🎬 MediaSourceInfo ID: \(self.id ?? "nil")")
|
|
||||||
print("🎬 MediaSourceInfo Name: \(self.name ?? "nil")")
|
|
||||||
print("🎬 Container: \(self.container ?? "nil")")
|
|
||||||
print("🎬 PlaySessionID: \(playSessionID)")
|
|
||||||
print("🎬 LiveStreamID: \(self.liveStreamID ?? "nil")")
|
|
||||||
print("🎬 OpenToken: \(self.openToken ?? "nil")")
|
|
||||||
|
|
||||||
// For Live TV: Try direct Dispatcharr proxy URL FIRST (Jellyfin's endpoints are broken)
|
|
||||||
if let path = self.path, let pathURL = URL(string: path), pathURL.scheme != nil {
|
|
||||||
// Use direct Dispatcharr proxy stream (MPEG-TS over HTTP)
|
|
||||||
playbackURL = pathURL
|
|
||||||
playMethod = .directPlay
|
|
||||||
print("🎬 Using direct Dispatcharr proxy path: \(playbackURL)")
|
|
||||||
print("🎬 Absolute URL: \(playbackURL.absoluteString)")
|
|
||||||
} else if let transcodingURL {
|
|
||||||
// Fallback to Jellyfin transcoding URL (doesn't work for Dispatcharr channels)
|
|
||||||
let liveTranscodingURL = transcodingURL.replacingOccurrences(of: "/master.m3u8", with: "/live.m3u8")
|
|
||||||
|
|
||||||
guard var fullTranscodeURL = userSession.client.fullURL(with: liveTranscodingURL)
|
|
||||||
else { throw JellyfinAPIError("Unable to make transcode URL") }
|
|
||||||
|
|
||||||
// Add LiveStreamId parameter using URLComponents for proper encoding
|
|
||||||
if let openToken = self.openToken, var components = URLComponents(url: fullTranscodeURL, resolvingAgainstBaseURL: false) {
|
|
||||||
var queryItems = components.queryItems ?? []
|
|
||||||
queryItems.append(URLQueryItem(name: "LiveStreamId", value: openToken))
|
|
||||||
components.queryItems = queryItems
|
|
||||||
|
|
||||||
if let urlWithLiveStreamId = components.url {
|
|
||||||
fullTranscodeURL = urlWithLiveStreamId
|
|
||||||
print("🎬 Added LiveStreamId parameter: \(openToken)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
playbackURL = fullTranscodeURL
|
playbackURL = fullTranscodeURL
|
||||||
playMethod = .transcode
|
playMethod = .transcode
|
||||||
print("🎬 Using live transcoding URL (converted from master): \(playbackURL)")
|
} else if self.isSupportsDirectPlay ?? false, let path = self.path, let playbackUrl = URL(string: path) {
|
||||||
print("🎬 Absolute URL: \(playbackURL.absoluteString)")
|
|
||||||
} else if false, let path = self.path, let pathURL = URL(string: path), pathURL.scheme != nil {
|
|
||||||
// Direct path disabled - fails with AVPlayer connection error
|
|
||||||
playbackURL = pathURL
|
|
||||||
playMethod = .directPlay
|
|
||||||
print("🎬 Using direct path URL (absolute): \(playbackURL)")
|
|
||||||
print("🎬 Absolute URL: \(playbackURL.absoluteString)")
|
|
||||||
} else if false, self.isSupportsDirectPlay ?? false, let path = self.path, let playbackUrl = URL(string: path) {
|
|
||||||
// Relative direct play disabled
|
|
||||||
playbackURL = playbackUrl
|
playbackURL = playbackUrl
|
||||||
playMethod = .directPlay
|
playMethod = .directPlay
|
||||||
print("🎬 Using direct play URL (relative): \(playbackURL)")
|
|
||||||
print("🎬 Absolute URL: \(playbackURL.absoluteString)")
|
|
||||||
} else {
|
} else {
|
||||||
// Use Jellyfin's live.m3u8 endpoint for Live TV (same as web browser)
|
let videoStreamParameters = Paths.GetVideoStreamParameters(
|
||||||
// Construct URL: /videos/{id}/live.m3u8?DeviceId=...&MediaSourceId=...&PlaySessionId=...&api_key=...
|
isStatic: true,
|
||||||
let deviceId = userSession.client.configuration.deviceID ?? "unknown"
|
tag: item.etag,
|
||||||
let apiKey = userSession.client.accessToken ?? ""
|
playSessionID: playSessionID,
|
||||||
|
mediaSourceID: id
|
||||||
|
)
|
||||||
|
|
||||||
var urlComponents = URLComponents()
|
let videoStreamRequest = Paths.getVideoStream(
|
||||||
urlComponents.scheme = userSession.server.currentURL.scheme
|
itemID: item.id!,
|
||||||
urlComponents.host = userSession.server.currentURL.host
|
parameters: videoStreamParameters
|
||||||
urlComponents.port = userSession.server.currentURL.port
|
)
|
||||||
urlComponents.path = "/videos/\(item.id!)/live.m3u8"
|
|
||||||
urlComponents.queryItems = [
|
|
||||||
URLQueryItem(name: "DeviceId", value: deviceId),
|
|
||||||
URLQueryItem(name: "MediaSourceId", value: id),
|
|
||||||
URLQueryItem(name: "PlaySessionId", value: playSessionID),
|
|
||||||
URLQueryItem(name: "api_key", value: apiKey),
|
|
||||||
]
|
|
||||||
|
|
||||||
guard let liveURL = urlComponents.url else {
|
guard let fullURL = userSession.client.fullURL(with: videoStreamRequest) else {
|
||||||
print("🎬 ERROR: Unable to construct live.m3u8 URL")
|
throw JellyfinAPIError("Unable to construct transcoded url")
|
||||||
throw JellyfinAPIError("Unable to construct live.m3u8 URL")
|
|
||||||
}
|
}
|
||||||
playbackURL = liveURL
|
playbackURL = fullURL
|
||||||
playMethod = .directPlay
|
playMethod = .directPlay
|
||||||
print("🎬 Using live.m3u8 URL: \(playbackURL)")
|
|
||||||
print("🎬 Absolute URL: \(playbackURL.absoluteString)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
print("🎬 Final playback URL absolute string: \(playbackURL.absoluteString)")
|
|
||||||
print("🎬 Play method: \(playMethod)")
|
|
||||||
|
|
||||||
let videoStreams = mediaStreams?.filter { $0.type == .video } ?? []
|
let videoStreams = mediaStreams?.filter { $0.type == .video } ?? []
|
||||||
let audioStreams = mediaStreams?.filter { $0.type == .audio } ?? []
|
let audioStreams = mediaStreams?.filter { $0.type == .audio } ?? []
|
||||||
let subtitleStreams = mediaStreams?.filter { $0.type == .subtitle } ?? []
|
let subtitleStreams = mediaStreams?.filter { $0.type == .subtitle } ?? []
|
||||||
|
|
|
@ -1,35 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
// TODO: rename as not only used in section footers
|
|
||||||
|
|
||||||
extension LabelStyle where Self == SectionFooterWithImageLabelStyle<AnyShapeStyle> {
|
|
||||||
|
|
||||||
static func sectionFooterWithImage<ImageStyle: ShapeStyle>(
|
|
||||||
imageStyle: ImageStyle
|
|
||||||
) -> SectionFooterWithImageLabelStyle<ImageStyle> {
|
|
||||||
SectionFooterWithImageLabelStyle(imageStyle: imageStyle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SectionFooterWithImageLabelStyle<ImageStyle: ShapeStyle>: LabelStyle {
|
|
||||||
|
|
||||||
let imageStyle: ImageStyle
|
|
||||||
|
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
|
||||||
HStack {
|
|
||||||
configuration.icon
|
|
||||||
.foregroundStyle(imageStyle)
|
|
||||||
.fontWeight(.bold)
|
|
||||||
|
|
||||||
configuration.title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -23,7 +23,7 @@ extension DataCache.Swiftfin {
|
||||||
|
|
||||||
static let posters: DataCache? = {
|
static let posters: DataCache? = {
|
||||||
|
|
||||||
let dataCache = try? DataCache(name: "se.ashik.jellyflood/Posters") { name in
|
let dataCache = try? DataCache(name: "org.ashik.jellypig/Posters") { name in
|
||||||
guard let url = name.url else { return nil }
|
guard let url = name.url else { return nil }
|
||||||
return ImagePipeline.cacheKey(for: url)
|
return ImagePipeline.cacheKey(for: url)
|
||||||
}
|
}
|
||||||
|
@ -40,7 +40,7 @@ extension DataCache.Swiftfin {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let path = root.appendingPathComponent("Caches/se.ashik.jellyflood.local", isDirectory: true)
|
let path = root.appendingPathComponent("Caches/org.ashik.jellypig.local", isDirectory: true)
|
||||||
|
|
||||||
let dataCache = try? DataCache(path: path) { name in
|
let dataCache = try? DataCache(path: path) { name in
|
||||||
|
|
||||||
|
|
|
@ -1,60 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import Defaults
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct GaugeProgressViewStyle: ProgressViewStyle {
|
|
||||||
|
|
||||||
@Default(.accentColor)
|
|
||||||
private var accentColor
|
|
||||||
|
|
||||||
@State
|
|
||||||
private var contentSize: CGSize = .zero
|
|
||||||
|
|
||||||
private let lineWidthRatio: CGFloat
|
|
||||||
private let systemImage: String?
|
|
||||||
|
|
||||||
init(systemImage: String? = nil) {
|
|
||||||
self.lineWidthRatio = systemImage == nil ? 0.2 : 0.125
|
|
||||||
self.systemImage = systemImage
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
|
||||||
ZStack {
|
|
||||||
|
|
||||||
if let systemImage {
|
|
||||||
Image(systemName: systemImage)
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fit)
|
|
||||||
.frame(maxWidth: contentSize.width / 2.5, maxHeight: contentSize.height / 2.5)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.padding(6)
|
|
||||||
}
|
|
||||||
|
|
||||||
Circle()
|
|
||||||
.stroke(
|
|
||||||
Color.gray.opacity(0.2),
|
|
||||||
lineWidth: contentSize.width * lineWidthRatio
|
|
||||||
)
|
|
||||||
|
|
||||||
Circle()
|
|
||||||
.trim(from: 0, to: configuration.fractionCompleted ?? 0)
|
|
||||||
.stroke(
|
|
||||||
accentColor,
|
|
||||||
style: StrokeStyle(
|
|
||||||
lineWidth: contentSize.width * lineWidthRatio,
|
|
||||||
lineCap: .round
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.rotationEffect(.degrees(-90))
|
|
||||||
}
|
|
||||||
.animation(.linear(duration: 0.1), value: configuration.fractionCompleted)
|
|
||||||
.trackingSize($contentSize)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,60 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct PlaybackProgressViewStyle: ProgressViewStyle {
|
|
||||||
|
|
||||||
enum CornerStyle {
|
|
||||||
case round
|
|
||||||
case square
|
|
||||||
}
|
|
||||||
|
|
||||||
@State
|
|
||||||
private var contentSize: CGSize = .zero
|
|
||||||
|
|
||||||
var secondaryProgress: Double?
|
|
||||||
var cornerStyle: CornerStyle
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func buildCapsule(for progress: Double) -> some View {
|
|
||||||
Rectangle()
|
|
||||||
.cornerRadius(
|
|
||||||
cornerStyle == .round ? contentSize.height / 2 : 0,
|
|
||||||
corners: [.topLeft, .bottomLeft]
|
|
||||||
)
|
|
||||||
.frame(width: contentSize.width * clamp(progress, min: 0, max: 1) + contentSize.height)
|
|
||||||
.offset(x: -contentSize.height)
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
|
||||||
Capsule()
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.opacity(0.2)
|
|
||||||
.overlay(alignment: .leading) {
|
|
||||||
ZStack(alignment: .leading) {
|
|
||||||
|
|
||||||
if let secondaryProgress,
|
|
||||||
secondaryProgress > 0
|
|
||||||
{
|
|
||||||
buildCapsule(for: secondaryProgress)
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let fractionCompleted = configuration.fractionCompleted {
|
|
||||||
buildCapsule(for: fractionCompleted)
|
|
||||||
.foregroundStyle(.primary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.trackingSize($contentSize)
|
|
||||||
.mask {
|
|
||||||
Capsule()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,33 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
extension ProgressViewStyle where Self == GaugeProgressViewStyle {
|
|
||||||
|
|
||||||
static var gauge: GaugeProgressViewStyle {
|
|
||||||
GaugeProgressViewStyle()
|
|
||||||
}
|
|
||||||
|
|
||||||
static func gauge(systemImage: String) -> GaugeProgressViewStyle {
|
|
||||||
GaugeProgressViewStyle(systemImage: systemImage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ProgressViewStyle where Self == PlaybackProgressViewStyle {
|
|
||||||
|
|
||||||
static var playback: Self { .init(secondaryProgress: nil, cornerStyle: .round) }
|
|
||||||
|
|
||||||
func secondaryProgress(_ progress: Double?) -> Self {
|
|
||||||
copy(self, modifying: \.secondaryProgress, to: progress)
|
|
||||||
}
|
|
||||||
|
|
||||||
var square: Self {
|
|
||||||
copy(self, modifying: \.cornerStyle, to: .square)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import Combine
|
|
||||||
|
|
||||||
/// A box for a `Published` value
|
|
||||||
class PublishedBox<Value>: ObservableObject {
|
|
||||||
|
|
||||||
@Published
|
|
||||||
var value: Value
|
|
||||||
|
|
||||||
init(initialValue: Value) {
|
|
||||||
self.value = initialValue
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,24 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
extension Section where Parent == Text, Footer == Text, Content: View {
|
|
||||||
|
|
||||||
init(
|
|
||||||
_ header: String,
|
|
||||||
footer: String,
|
|
||||||
@ViewBuilder content: @escaping () -> Content
|
|
||||||
) {
|
|
||||||
self.init(content: content) {
|
|
||||||
Text(header)
|
|
||||||
} footer: {
|
|
||||||
Text(footer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,81 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension UIImage {
|
|
||||||
|
|
||||||
func getTileImage(
|
|
||||||
columns: Int,
|
|
||||||
rows: Int,
|
|
||||||
index: Int
|
|
||||||
) -> UIImage? {
|
|
||||||
let x = index % columns
|
|
||||||
let y = index / columns
|
|
||||||
|
|
||||||
// Check if the tile index is within the valid range
|
|
||||||
// guard x >= 0, y >= 0, x < columns, y < rows else {
|
|
||||||
// return nil
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Use integer arithmetic for tile dimensions and positions
|
|
||||||
let imageWidth = Int(size.width)
|
|
||||||
let imageHeight = Int(size.height)
|
|
||||||
let tileWidth = imageWidth / columns
|
|
||||||
let tileHeight = imageHeight / rows
|
|
||||||
|
|
||||||
// Calculate the rectangle using integer values
|
|
||||||
let rect = CGRect(
|
|
||||||
x: x * tileWidth,
|
|
||||||
y: y * tileHeight,
|
|
||||||
width: tileWidth,
|
|
||||||
height: tileHeight
|
|
||||||
)
|
|
||||||
|
|
||||||
// This check is now redundant because of the earlier guard statement
|
|
||||||
// guard rect.maxX <= imageWidth && rect.maxY <= imageHeight else {
|
|
||||||
// return nil
|
|
||||||
// }
|
|
||||||
|
|
||||||
if let cgImage = cgImage?.cropping(to: rect) {
|
|
||||||
return UIImage(cgImage: cgImage)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
|
|
||||||
// guard index >= 0 else {
|
|
||||||
// return nil
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// let imageWidth = size.width
|
|
||||||
// let imageHeight = size.height
|
|
||||||
//
|
|
||||||
// let tileWidth = imageWidth / CGFloat(columns)
|
|
||||||
// let tileHeight = imageHeight / CGFloat(rows)
|
|
||||||
//
|
|
||||||
// let x = (index % columns)
|
|
||||||
// let y = (index / columns)
|
|
||||||
//
|
|
||||||
// let rect = CGRect(
|
|
||||||
// x: CGFloat(x) * tileWidth,
|
|
||||||
// y: CGFloat(y) * tileHeight,
|
|
||||||
// width: tileWidth,
|
|
||||||
// height: tileHeight
|
|
||||||
// )
|
|
||||||
//
|
|
||||||
// guard rect.maxX <= imageWidth && rect.maxY <= imageHeight else {
|
|
||||||
// return nil
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// if let cgImage = cgImage?.cropping(to: rect) {
|
|
||||||
// return UIImage(cgImage: cgImage)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// return nil
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
extension UnitPoint {
|
|
||||||
|
|
||||||
var inverted: UnitPoint {
|
|
||||||
UnitPoint(x: 1 - x, y: 1 - y)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,217 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
// swiftlint:disable all
|
|
||||||
// Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen
|
|
||||||
|
|
||||||
#if os(macOS)
|
|
||||||
import AppKit
|
|
||||||
#elseif os(iOS)
|
|
||||||
import UIKit
|
|
||||||
#elseif os(tvOS) || os(watchOS)
|
|
||||||
import UIKit
|
|
||||||
#endif
|
|
||||||
#if canImport(SwiftUI)
|
|
||||||
import SwiftUI
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Deprecated typealiases
|
|
||||||
@available(*, deprecated, renamed: "ImageAsset.Image", message: "This typealias will be removed in SwiftGen 7.0")
|
|
||||||
typealias AssetImageTypeAlias = ImageAsset.Image
|
|
||||||
|
|
||||||
// swiftlint:disable superfluous_disable_command file_length implicit_return
|
|
||||||
|
|
||||||
// MARK: - Asset Catalogs
|
|
||||||
|
|
||||||
// swiftlint:disable identifier_name line_length nesting type_body_length type_name
|
|
||||||
enum Asset {
|
|
||||||
static let deviceBrowserChrome = ImageAsset(name: "Device-browser-chrome")
|
|
||||||
static let deviceBrowserEdge = ImageAsset(name: "Device-browser-edge")
|
|
||||||
static let deviceBrowserEdgechromium = ImageAsset(name: "Device-browser-edgechromium")
|
|
||||||
static let deviceBrowserFirefox = ImageAsset(name: "Device-browser-firefox")
|
|
||||||
static let deviceBrowserHtml5 = ImageAsset(name: "Device-browser-html5")
|
|
||||||
static let deviceBrowserMsie = ImageAsset(name: "Device-browser-msie")
|
|
||||||
static let deviceBrowserOpera = ImageAsset(name: "Device-browser-opera")
|
|
||||||
static let deviceBrowserSafari = ImageAsset(name: "Device-browser-safari")
|
|
||||||
static let deviceClientAndroid = ImageAsset(name: "Device-client-android")
|
|
||||||
static let deviceClientApple = ImageAsset(name: "Device-client-apple")
|
|
||||||
static let deviceClientFinamp = ImageAsset(name: "Device-client-finamp")
|
|
||||||
static let deviceClientKodi = ImageAsset(name: "Device-client-kodi")
|
|
||||||
static let deviceClientPlaystation = ImageAsset(name: "Device-client-playstation")
|
|
||||||
static let deviceClientRoku = ImageAsset(name: "Device-client-roku")
|
|
||||||
static let deviceClientSamsungtv = ImageAsset(name: "Device-client-samsungtv")
|
|
||||||
static let deviceClientWebos = ImageAsset(name: "Device-client-webos")
|
|
||||||
static let deviceClientWindows = ImageAsset(name: "Device-client-windows")
|
|
||||||
static let deviceClientXbox = ImageAsset(name: "Device-client-xbox")
|
|
||||||
static let deviceOtherHomeassistant = ImageAsset(name: "Device-other-homeassistant")
|
|
||||||
static let deviceOtherOther = ImageAsset(name: "Device-other-other")
|
|
||||||
static let jellyfinBlobBlue = ImageAsset(name: "jellyfin-blob-blue")
|
|
||||||
static let tomatoFresh = SymbolAsset(name: "tomato.fresh")
|
|
||||||
static let tomatoRotten = SymbolAsset(name: "tomato.rotten")
|
|
||||||
}
|
|
||||||
|
|
||||||
// swiftlint:enable identifier_name line_length nesting type_body_length type_name
|
|
||||||
|
|
||||||
// MARK: - Implementation Details
|
|
||||||
|
|
||||||
struct ImageAsset {
|
|
||||||
fileprivate(set) var name: String
|
|
||||||
|
|
||||||
#if os(macOS)
|
|
||||||
typealias Image = NSImage
|
|
||||||
#elseif os(iOS) || os(tvOS) || os(watchOS)
|
|
||||||
typealias Image = UIImage
|
|
||||||
#endif
|
|
||||||
|
|
||||||
@available(iOS 8.0, tvOS 9.0, watchOS 2.0, macOS 10.7, *)
|
|
||||||
var image: Image {
|
|
||||||
let bundle = BundleToken.bundle
|
|
||||||
#if os(iOS) || os(tvOS)
|
|
||||||
let image = Image(named: name, in: bundle, compatibleWith: nil)
|
|
||||||
#elseif os(macOS)
|
|
||||||
let name = NSImage.Name(self.name)
|
|
||||||
let image = (bundle == .main) ? NSImage(named: name) : bundle.image(forResource: name)
|
|
||||||
#elseif os(watchOS)
|
|
||||||
let image = Image(named: name)
|
|
||||||
#endif
|
|
||||||
guard let result = image else {
|
|
||||||
fatalError("Unable to load image asset named \(name).")
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS) || os(tvOS)
|
|
||||||
@available(iOS 8.0, tvOS 9.0, *)
|
|
||||||
func image(compatibleWith traitCollection: UITraitCollection) -> Image {
|
|
||||||
let bundle = BundleToken.bundle
|
|
||||||
guard let result = Image(named: name, in: bundle, compatibleWith: traitCollection) else {
|
|
||||||
fatalError("Unable to load image asset named \(name).")
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if canImport(SwiftUI)
|
|
||||||
@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *)
|
|
||||||
var swiftUIImage: SwiftUI.Image {
|
|
||||||
SwiftUI.Image(asset: self)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ImageAsset.Image {
|
|
||||||
@available(iOS 8.0, tvOS 9.0, watchOS 2.0, *)
|
|
||||||
@available(
|
|
||||||
macOS,
|
|
||||||
deprecated,
|
|
||||||
message: "This initializer is unsafe on macOS, please use the ImageAsset.image property"
|
|
||||||
)
|
|
||||||
convenience init?(asset: ImageAsset) {
|
|
||||||
#if os(iOS) || os(tvOS)
|
|
||||||
let bundle = BundleToken.bundle
|
|
||||||
self.init(named: asset.name, in: bundle, compatibleWith: nil)
|
|
||||||
#elseif os(macOS)
|
|
||||||
self.init(named: NSImage.Name(asset.name))
|
|
||||||
#elseif os(watchOS)
|
|
||||||
self.init(named: asset.name)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if canImport(SwiftUI)
|
|
||||||
@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *)
|
|
||||||
extension SwiftUI.Image {
|
|
||||||
init(asset: ImageAsset) {
|
|
||||||
let bundle = BundleToken.bundle
|
|
||||||
self.init(asset.name, bundle: bundle)
|
|
||||||
}
|
|
||||||
|
|
||||||
init(asset: ImageAsset, label: Text) {
|
|
||||||
let bundle = BundleToken.bundle
|
|
||||||
self.init(asset.name, bundle: bundle, label: label)
|
|
||||||
}
|
|
||||||
|
|
||||||
init(decorative asset: ImageAsset) {
|
|
||||||
let bundle = BundleToken.bundle
|
|
||||||
self.init(decorative: asset.name, bundle: bundle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
struct SymbolAsset {
|
|
||||||
fileprivate(set) var name: String
|
|
||||||
|
|
||||||
#if os(iOS) || os(tvOS) || os(watchOS)
|
|
||||||
@available(iOS 13.0, tvOS 13.0, watchOS 6.0, *)
|
|
||||||
typealias Configuration = UIImage.SymbolConfiguration
|
|
||||||
typealias Image = UIImage
|
|
||||||
|
|
||||||
@available(iOS 12.0, tvOS 12.0, watchOS 5.0, *)
|
|
||||||
var image: Image {
|
|
||||||
let bundle = BundleToken.bundle
|
|
||||||
#if os(iOS) || os(tvOS)
|
|
||||||
let image = Image(named: name, in: bundle, compatibleWith: nil)
|
|
||||||
#elseif os(watchOS)
|
|
||||||
let image = Image(named: name)
|
|
||||||
#endif
|
|
||||||
guard let result = image else {
|
|
||||||
fatalError("Unable to load symbol asset named \(name).")
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 13.0, tvOS 13.0, watchOS 6.0, *)
|
|
||||||
func image(with configuration: Configuration) -> Image {
|
|
||||||
let bundle = BundleToken.bundle
|
|
||||||
guard let result = Image(named: name, in: bundle, with: configuration) else {
|
|
||||||
fatalError("Unable to load symbol asset named \(name).")
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if canImport(SwiftUI)
|
|
||||||
@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *)
|
|
||||||
var swiftUIImage: SwiftUI.Image {
|
|
||||||
SwiftUI.Image(asset: self)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
#if canImport(SwiftUI)
|
|
||||||
@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *)
|
|
||||||
extension SwiftUI.Image {
|
|
||||||
init(asset: SymbolAsset) {
|
|
||||||
let bundle = BundleToken.bundle
|
|
||||||
self.init(asset.name, bundle: bundle)
|
|
||||||
}
|
|
||||||
|
|
||||||
init(asset: SymbolAsset, label: Text) {
|
|
||||||
let bundle = BundleToken.bundle
|
|
||||||
self.init(asset.name, bundle: bundle, label: label)
|
|
||||||
}
|
|
||||||
|
|
||||||
init(decorative asset: SymbolAsset) {
|
|
||||||
let bundle = BundleToken.bundle
|
|
||||||
self.init(decorative: asset.name, bundle: bundle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// swiftlint:disable convenience_type
|
|
||||||
private final class BundleToken {
|
|
||||||
static let bundle: Bundle = {
|
|
||||||
#if SWIFT_PACKAGE
|
|
||||||
return Bundle.module
|
|
||||||
#else
|
|
||||||
return Bundle(for: BundleToken.self)
|
|
||||||
#endif
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// swiftlint:enable convenience_type
|
|
|
@ -1,73 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import Factory
|
|
||||||
import Logging
|
|
||||||
|
|
||||||
extension Logger {
|
|
||||||
|
|
||||||
static func swiftfin() -> Logger {
|
|
||||||
Logger(label: "org.jellyfin.swiftfin")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SwiftfinConsoleHandler: LogHandler {
|
|
||||||
|
|
||||||
var logLevel: Logger.Level = .trace
|
|
||||||
var metadata: Logger.Metadata = [:]
|
|
||||||
|
|
||||||
subscript(metadataKey key: String) -> Logger.Metadata.Value? {
|
|
||||||
get {
|
|
||||||
metadata[key]
|
|
||||||
}
|
|
||||||
set(newValue) {
|
|
||||||
metadata[key] = newValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func log(
|
|
||||||
level: Logger.Level,
|
|
||||||
message: Logger.Message,
|
|
||||||
metadata: Logger.Metadata?,
|
|
||||||
source: String,
|
|
||||||
file: String,
|
|
||||||
function: String,
|
|
||||||
line: UInt
|
|
||||||
) {
|
|
||||||
let line = "[\(level.emoji) \(level.rawValue.capitalized)] \(file.shortFileName)#\(line):\(function) \(message)"
|
|
||||||
let meta = (metadata ?? [:]).merging(self.metadata) { _, new in new }
|
|
||||||
let metadataString = meta.map { "\t- \($0): \($1)" }.joined(separator: "\n")
|
|
||||||
|
|
||||||
print(line)
|
|
||||||
|
|
||||||
if metadataString.isNotEmpty {
|
|
||||||
print(metadataString)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Logger.Level {
|
|
||||||
var emoji: String {
|
|
||||||
switch self {
|
|
||||||
case .trace:
|
|
||||||
return "🟣"
|
|
||||||
case .debug:
|
|
||||||
return "🔵"
|
|
||||||
case .info:
|
|
||||||
return "🟢"
|
|
||||||
case .notice:
|
|
||||||
return "🟠"
|
|
||||||
case .warning:
|
|
||||||
return "🟡"
|
|
||||||
case .error:
|
|
||||||
return "🔴"
|
|
||||||
case .critical:
|
|
||||||
return "💥"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,57 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import JellyfinAPI
|
|
||||||
import Pulse
|
|
||||||
|
|
||||||
private let redactedMessage = "<Redacted by Swiftfin>"
|
|
||||||
|
|
||||||
extension NetworkLogger {
|
|
||||||
|
|
||||||
static func swiftfin() -> NetworkLogger {
|
|
||||||
var configuration = NetworkLogger.Configuration()
|
|
||||||
|
|
||||||
configuration.willHandleEvent = { event -> LoggerStore.Event? in
|
|
||||||
if case var LoggerStore.Event.networkTaskCompleted(task) = event {
|
|
||||||
guard let url = task.originalRequest.url,
|
|
||||||
let requestBody = task.requestBody
|
|
||||||
else {
|
|
||||||
return event
|
|
||||||
}
|
|
||||||
|
|
||||||
let pathComponents = url.pathComponents
|
|
||||||
|
|
||||||
if pathComponents.last == "AuthenticateByName",
|
|
||||||
var body = try? JSONDecoder().decode(AuthenticateUserByName.self, from: requestBody)
|
|
||||||
{
|
|
||||||
body.pw = redactedMessage
|
|
||||||
task.requestBody = try? JSONEncoder().encode(body)
|
|
||||||
|
|
||||||
return LoggerStore.Event.networkTaskCompleted(task)
|
|
||||||
}
|
|
||||||
|
|
||||||
if pathComponents.last == "Password",
|
|
||||||
var body = try? JSONDecoder().decode(UpdateUserPassword.self, from: requestBody)
|
|
||||||
{
|
|
||||||
body.currentPassword = redactedMessage
|
|
||||||
body.currentPw = redactedMessage
|
|
||||||
body.newPw = redactedMessage
|
|
||||||
body.isResetPassword = nil
|
|
||||||
task.requestBody = try? JSONEncoder().encode(body)
|
|
||||||
|
|
||||||
return LoggerStore.Event.networkTaskCompleted(task)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return event
|
|
||||||
}
|
|
||||||
|
|
||||||
return NetworkLogger(configuration: configuration)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,84 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import CoreStore
|
|
||||||
import Logging
|
|
||||||
|
|
||||||
struct SwiftfinCorestoreLogger: CoreStoreLogger {
|
|
||||||
|
|
||||||
private let logger = Logger.swiftfin()
|
|
||||||
|
|
||||||
func log(
|
|
||||||
error: CoreStoreError,
|
|
||||||
message: String,
|
|
||||||
fileName: StaticString,
|
|
||||||
lineNumber: Int,
|
|
||||||
functionName: StaticString
|
|
||||||
) {
|
|
||||||
logger.error(
|
|
||||||
"\(message)",
|
|
||||||
metadata: nil,
|
|
||||||
source: "Corestore",
|
|
||||||
file: fileName.description,
|
|
||||||
function: functionName.description,
|
|
||||||
line: UInt(lineNumber)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func log(
|
|
||||||
level: LogLevel,
|
|
||||||
message: String,
|
|
||||||
fileName: StaticString,
|
|
||||||
lineNumber: Int,
|
|
||||||
functionName: StaticString
|
|
||||||
) {
|
|
||||||
logger.log(
|
|
||||||
level: level.asSwiftLog,
|
|
||||||
"\(message)",
|
|
||||||
metadata: nil,
|
|
||||||
source: "Corestore",
|
|
||||||
file: fileName.description,
|
|
||||||
function: functionName.description,
|
|
||||||
line: UInt(lineNumber)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func assert(
|
|
||||||
_ condition: @autoclosure () -> Bool,
|
|
||||||
message: @autoclosure () -> String,
|
|
||||||
fileName: StaticString,
|
|
||||||
lineNumber: Int,
|
|
||||||
functionName: StaticString
|
|
||||||
) {
|
|
||||||
guard !condition() else { return }
|
|
||||||
logger.critical(
|
|
||||||
"\(message())",
|
|
||||||
metadata: nil,
|
|
||||||
source: "Corestore",
|
|
||||||
file: fileName.description,
|
|
||||||
function: functionName.description,
|
|
||||||
line: UInt(lineNumber)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension CoreStore.LogLevel {
|
|
||||||
|
|
||||||
var asSwiftLog: Logger.Level {
|
|
||||||
switch self {
|
|
||||||
case .trace:
|
|
||||||
return .trace
|
|
||||||
case .notice:
|
|
||||||
return .debug
|
|
||||||
case .warning:
|
|
||||||
return .warning
|
|
||||||
case .fatal:
|
|
||||||
return .critical
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,38 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
enum ActiveSessionFilter: String, CaseIterable, SystemImageable, Displayable, Storable {
|
|
||||||
|
|
||||||
case all
|
|
||||||
case active
|
|
||||||
case inactive
|
|
||||||
|
|
||||||
var displayTitle: String {
|
|
||||||
switch self {
|
|
||||||
case .all:
|
|
||||||
return L10n.all
|
|
||||||
case .active:
|
|
||||||
return L10n.active
|
|
||||||
case .inactive:
|
|
||||||
return L10n.inactive
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var systemImage: String {
|
|
||||||
switch self {
|
|
||||||
case .all:
|
|
||||||
return "line.3.horizontal"
|
|
||||||
case .active:
|
|
||||||
return "play"
|
|
||||||
case .inactive:
|
|
||||||
return "play.slash"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -53,20 +53,4 @@ extension ChannelProgram: Poster {
|
||||||
var systemImage: String {
|
var systemImage: String {
|
||||||
channel.systemImage
|
channel.systemImage
|
||||||
}
|
}
|
||||||
|
|
||||||
func portraitImageSources(maxWidth: CGFloat? = nil, quality: Int? = nil) -> [ImageSource] {
|
|
||||||
channel.portraitImageSources(maxWidth: maxWidth)
|
|
||||||
}
|
|
||||||
|
|
||||||
func landscapeImageSources(maxWidth: CGFloat? = nil, quality: Int? = nil) -> [ImageSource] {
|
|
||||||
channel.landscapeImageSources(maxWidth: maxWidth)
|
|
||||||
}
|
|
||||||
|
|
||||||
func cinematicImageSources(maxWidth: CGFloat? = nil, quality: Int? = nil) -> [ImageSource] {
|
|
||||||
channel.cinematicImageSources(maxWidth: maxWidth)
|
|
||||||
}
|
|
||||||
|
|
||||||
func squareImageSources(maxWidth: CGFloat? = nil, quality: Int? = nil) -> [ImageSource] {
|
|
||||||
channel.squareImageSources(maxWidth: maxWidth)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,44 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class DirectionalPanGestureRecognizer: UIPanGestureRecognizer {
|
|
||||||
|
|
||||||
var direction: Direction
|
|
||||||
|
|
||||||
init(direction: Direction, target: AnyObject, action: Selector) {
|
|
||||||
self.direction = direction
|
|
||||||
super.init(target: target, action: action)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
|
|
||||||
super.touchesMoved(touches, with: event)
|
|
||||||
|
|
||||||
if state == .began {
|
|
||||||
let velocity = velocity(in: view)
|
|
||||||
|
|
||||||
let isUp = velocity.y < 0
|
|
||||||
let isHorizontal = velocity.y.magnitude < velocity.x.magnitude
|
|
||||||
let isVertical = velocity.x.magnitude < velocity.y.magnitude
|
|
||||||
|
|
||||||
switch direction {
|
|
||||||
case .all: ()
|
|
||||||
case .allButDown where isUp || isHorizontal: ()
|
|
||||||
case .horizontal where isHorizontal: ()
|
|
||||||
case .vertical where isVertical: ()
|
|
||||||
case .up where isVertical && velocity.y < 0: ()
|
|
||||||
case .down where isVertical && velocity.y > 0: ()
|
|
||||||
case .left where isHorizontal && velocity.x < 0: ()
|
|
||||||
case .right where isHorizontal && velocity.x > 0: ()
|
|
||||||
default:
|
|
||||||
state = .cancelled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,28 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
enum DoubleTouchGestureAction: String, GestureAction {
|
|
||||||
|
|
||||||
case none
|
|
||||||
case aspectFill
|
|
||||||
case gestureLock
|
|
||||||
case pausePlay
|
|
||||||
|
|
||||||
var displayTitle: String {
|
|
||||||
switch self {
|
|
||||||
case .none:
|
|
||||||
return L10n.none
|
|
||||||
case .aspectFill:
|
|
||||||
return L10n.aspectFill
|
|
||||||
case .gestureLock:
|
|
||||||
return L10n.gestureLock
|
|
||||||
case .pausePlay:
|
|
||||||
return L10n.playAndPause
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,12 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
// `none` is used since values aren't supported in Defaults
|
|
||||||
// https://github.com/sindresorhus/Defaults/issues/54
|
|
||||||
|
|
||||||
protocol GestureAction: CaseIterable, Displayable, Storable {}
|
|
|
@ -1,22 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
enum LongPressGestureAction: String, GestureAction {
|
|
||||||
|
|
||||||
case none
|
|
||||||
case gestureLock
|
|
||||||
|
|
||||||
var displayTitle: String {
|
|
||||||
switch self {
|
|
||||||
case .none:
|
|
||||||
return L10n.none
|
|
||||||
case .gestureLock:
|
|
||||||
return L10n.gestureLock
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,22 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
enum MultiTapGestureAction: String, GestureAction {
|
|
||||||
|
|
||||||
case none
|
|
||||||
case jump
|
|
||||||
|
|
||||||
var displayTitle: String {
|
|
||||||
switch self {
|
|
||||||
case .none:
|
|
||||||
return L10n.none
|
|
||||||
case .jump:
|
|
||||||
return L10n.jump
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,31 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
enum PanGestureAction: String, GestureAction {
|
|
||||||
|
|
||||||
case none
|
|
||||||
case brightness
|
|
||||||
case scrub
|
|
||||||
case slowScrub
|
|
||||||
case volume
|
|
||||||
|
|
||||||
var displayTitle: String {
|
|
||||||
switch self {
|
|
||||||
case .none:
|
|
||||||
return L10n.none
|
|
||||||
case .brightness:
|
|
||||||
return L10n.brightness
|
|
||||||
case .scrub:
|
|
||||||
return L10n.scrub
|
|
||||||
case .slowScrub:
|
|
||||||
return L10n.slowScrub
|
|
||||||
case .volume:
|
|
||||||
return L10n.volume
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,22 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
enum PinchGestureAction: String, GestureAction {
|
|
||||||
|
|
||||||
case none
|
|
||||||
case aspectFill
|
|
||||||
|
|
||||||
var displayTitle: String {
|
|
||||||
switch self {
|
|
||||||
case .none:
|
|
||||||
return L10n.none
|
|
||||||
case .aspectFill:
|
|
||||||
return L10n.aspectFill
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,22 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
enum SwipeGestureAction: String, GestureAction {
|
|
||||||
|
|
||||||
case none
|
|
||||||
case jump
|
|
||||||
|
|
||||||
var displayTitle: String {
|
|
||||||
switch self {
|
|
||||||
case .none:
|
|
||||||
return L10n.none
|
|
||||||
case .jump:
|
|
||||||
return L10n.jump
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,17 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct IsStatusBarHiddenKey: PreferenceKey {
|
|
||||||
static var defaultValue: Bool = false
|
|
||||||
|
|
||||||
static func reduce(value: inout Bool, nextValue: () -> Bool) {
|
|
||||||
value = nextValue() || value
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,54 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@propertyWrapper
|
|
||||||
struct LazyState<Value>: @preconcurrency DynamicProperty {
|
|
||||||
|
|
||||||
final class Box {
|
|
||||||
|
|
||||||
private var value: Value!
|
|
||||||
private let thunk: () -> Value
|
|
||||||
var didThunk = false
|
|
||||||
|
|
||||||
var wrappedValue: Value {
|
|
||||||
value
|
|
||||||
}
|
|
||||||
|
|
||||||
func setup() {
|
|
||||||
value = thunk()
|
|
||||||
didThunk = true
|
|
||||||
}
|
|
||||||
|
|
||||||
init(wrappedValue thunk: @autoclosure @escaping () -> Value) {
|
|
||||||
self.thunk = thunk
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@State
|
|
||||||
private var holder: Box
|
|
||||||
|
|
||||||
var wrappedValue: Value {
|
|
||||||
holder.wrappedValue
|
|
||||||
}
|
|
||||||
|
|
||||||
var projectedValue: Binding<Value> {
|
|
||||||
Binding(get: { wrappedValue }, set: { _ in })
|
|
||||||
}
|
|
||||||
|
|
||||||
func update() {
|
|
||||||
guard !holder.didThunk else { return }
|
|
||||||
holder.setup()
|
|
||||||
}
|
|
||||||
|
|
||||||
init(wrappedValue thunk: @autoclosure @escaping () -> Value) {
|
|
||||||
_holder = State(wrappedValue: Box(wrappedValue: thunk()))
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,83 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import Defaults
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
// TODO: conform to `SystemImageable`
|
|
||||||
// - forward to systemImage, backward to secondarySystemImage
|
|
||||||
enum MediaJumpInterval: Storable, RawRepresentable {
|
|
||||||
|
|
||||||
typealias RawValue = Duration
|
|
||||||
|
|
||||||
case five
|
|
||||||
case ten
|
|
||||||
case fifteen
|
|
||||||
case thirty
|
|
||||||
case custom(interval: Duration)
|
|
||||||
|
|
||||||
init?(rawValue: Duration) {
|
|
||||||
switch rawValue {
|
|
||||||
case .seconds(5):
|
|
||||||
self = .five
|
|
||||||
case .seconds(10):
|
|
||||||
self = .ten
|
|
||||||
case .seconds(15):
|
|
||||||
self = .fifteen
|
|
||||||
case .seconds(30):
|
|
||||||
self = .thirty
|
|
||||||
default:
|
|
||||||
self = .custom(interval: rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var rawValue: Duration {
|
|
||||||
switch self {
|
|
||||||
case .five:
|
|
||||||
.seconds(5)
|
|
||||||
case .ten:
|
|
||||||
.seconds(10)
|
|
||||||
case .fifteen:
|
|
||||||
.seconds(15)
|
|
||||||
case .thirty:
|
|
||||||
.seconds(30)
|
|
||||||
case let .custom(interval):
|
|
||||||
interval
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var forwardSystemImage: String {
|
|
||||||
switch self {
|
|
||||||
case .thirty:
|
|
||||||
"goforward.30"
|
|
||||||
case .fifteen:
|
|
||||||
"goforward.15"
|
|
||||||
case .ten:
|
|
||||||
"goforward.10"
|
|
||||||
case .five:
|
|
||||||
"goforward.5"
|
|
||||||
case .custom:
|
|
||||||
"goforward"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var backwardSystemImage: String {
|
|
||||||
switch self {
|
|
||||||
case .thirty:
|
|
||||||
"gobackward.30"
|
|
||||||
case .fifteen:
|
|
||||||
"gobackward.15"
|
|
||||||
case .ten:
|
|
||||||
"gobackward.10"
|
|
||||||
case .five:
|
|
||||||
"gobackward.5"
|
|
||||||
case .custom:
|
|
||||||
"gobackward"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,218 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import Defaults
|
|
||||||
import Factory
|
|
||||||
import Foundation
|
|
||||||
import JellyfinAPI
|
|
||||||
import Logging
|
|
||||||
|
|
||||||
// TODO: build report of determined values for playback information
|
|
||||||
// - transcode, video stream, path
|
|
||||||
|
|
||||||
extension MediaPlayerItem {
|
|
||||||
|
|
||||||
/// The main `MediaPlayerItem` builder for normal online usage.
|
|
||||||
static func build(
|
|
||||||
for initialItem: BaseItemDto,
|
|
||||||
mediaSource _initialMediaSource: MediaSourceInfo? = nil,
|
|
||||||
videoPlayerType: VideoPlayerType = Defaults[.VideoPlayer.videoPlayerType],
|
|
||||||
requestedBitrate: PlaybackBitrate = Defaults[.VideoPlayer.Playback.appMaximumBitrate],
|
|
||||||
compatibilityMode: PlaybackCompatibility = Defaults[.VideoPlayer.Playback.compatibilityMode],
|
|
||||||
modifyItem: ((inout BaseItemDto) -> Void)? = nil
|
|
||||||
) async throws -> MediaPlayerItem {
|
|
||||||
|
|
||||||
let logger = Logger.swiftfin()
|
|
||||||
|
|
||||||
guard let itemID = initialItem.id else {
|
|
||||||
logger.critical("No item ID!")
|
|
||||||
throw JellyfinAPIError(L10n.unknownError)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let userSession = Container.shared.currentUserSession() else {
|
|
||||||
logger.critical("No user session!")
|
|
||||||
throw JellyfinAPIError(L10n.unknownError)
|
|
||||||
}
|
|
||||||
|
|
||||||
var item = try await initialItem.getFullItem(userSession: userSession)
|
|
||||||
|
|
||||||
if let modifyItem {
|
|
||||||
modifyItem(&item)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let initialMediaSource = {
|
|
||||||
if let _initialMediaSource {
|
|
||||||
return _initialMediaSource
|
|
||||||
}
|
|
||||||
|
|
||||||
if let first = item.mediaSources?.first {
|
|
||||||
logger.trace("Using first media source for item \(itemID)")
|
|
||||||
return first
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}() else {
|
|
||||||
logger.error("No media sources for item \(itemID)!")
|
|
||||||
throw JellyfinAPIError(L10n.unknownError)
|
|
||||||
}
|
|
||||||
|
|
||||||
let maxBitrate = try await requestedBitrate.getMaxBitrate()
|
|
||||||
|
|
||||||
let deviceProfile = DeviceProfile.build(
|
|
||||||
for: videoPlayerType,
|
|
||||||
compatibilityMode: compatibilityMode,
|
|
||||||
maxBitrate: maxBitrate
|
|
||||||
)
|
|
||||||
|
|
||||||
var playbackInfo = PlaybackInfoDto()
|
|
||||||
playbackInfo.isAutoOpenLiveStream = true
|
|
||||||
playbackInfo.deviceProfile = deviceProfile
|
|
||||||
playbackInfo.liveStreamID = initialMediaSource.liveStreamID
|
|
||||||
playbackInfo.maxStreamingBitrate = maxBitrate
|
|
||||||
playbackInfo.userID = userSession.user.id
|
|
||||||
|
|
||||||
let request = Paths.getPostedPlaybackInfo(
|
|
||||||
itemID: itemID,
|
|
||||||
playbackInfo
|
|
||||||
)
|
|
||||||
|
|
||||||
let response = try await userSession.client.send(request)
|
|
||||||
|
|
||||||
let mediaSource: MediaSourceInfo? = {
|
|
||||||
|
|
||||||
guard let mediaSources = response.value.mediaSources else { return nil }
|
|
||||||
|
|
||||||
if let matchingTag = mediaSources.first(where: { $0.eTag == initialMediaSource.eTag }) {
|
|
||||||
return matchingTag
|
|
||||||
}
|
|
||||||
|
|
||||||
for source in mediaSources {
|
|
||||||
if let openToken = source.openToken,
|
|
||||||
let id = source.id,
|
|
||||||
openToken.contains(id)
|
|
||||||
{
|
|
||||||
return source
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.warning("Unable to find matching media source, defaulting to first media source")
|
|
||||||
|
|
||||||
return mediaSources.first
|
|
||||||
}()
|
|
||||||
|
|
||||||
guard let mediaSource else {
|
|
||||||
throw JellyfinAPIError("Unable to find media source for item")
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let playSessionID = response.value.playSessionID else {
|
|
||||||
throw JellyfinAPIError("No associated play session ID")
|
|
||||||
}
|
|
||||||
|
|
||||||
let playbackURL = try Self.streamURL(
|
|
||||||
item: item,
|
|
||||||
mediaSource: mediaSource,
|
|
||||||
playSessionID: playSessionID,
|
|
||||||
userSession: userSession,
|
|
||||||
logger: logger
|
|
||||||
)
|
|
||||||
|
|
||||||
let previewImageProvider: (any PreviewImageProvider)? = {
|
|
||||||
let previewImageScrubbingSetting = StoredValues[.User.previewImageScrubbing]
|
|
||||||
lazy var chapterPreviewImageProvider: ChapterPreviewImageProvider? = {
|
|
||||||
if let chapters = item.fullChapterInfo, chapters.isNotEmpty {
|
|
||||||
return ChapterPreviewImageProvider(chapters: chapters)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}()
|
|
||||||
|
|
||||||
if case let PreviewImageScrubbingOption.trickplay(fallbackToChapters: fallbackToChapters) = previewImageScrubbingSetting {
|
|
||||||
if let mediaSourceID = mediaSource.id,
|
|
||||||
let trickplayInfo = item.trickplay?[mediaSourceID]?.first
|
|
||||||
{
|
|
||||||
return TrickplayPreviewImageProvider(
|
|
||||||
info: trickplayInfo.value,
|
|
||||||
itemID: itemID,
|
|
||||||
mediaSourceID: mediaSourceID,
|
|
||||||
runtime: item.runtime ?? .zero
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if fallbackToChapters {
|
|
||||||
return chapterPreviewImageProvider
|
|
||||||
}
|
|
||||||
} else if previewImageScrubbingSetting == .chapters {
|
|
||||||
return chapterPreviewImageProvider
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}()
|
|
||||||
|
|
||||||
return .init(
|
|
||||||
baseItem: item,
|
|
||||||
mediaSource: mediaSource,
|
|
||||||
playSessionID: playSessionID,
|
|
||||||
url: playbackURL,
|
|
||||||
requestedBitrate: requestedBitrate,
|
|
||||||
previewImageProvider: previewImageProvider,
|
|
||||||
thumbnailProvider: item.getNowPlayingImage
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: audio type stream
|
|
||||||
// TODO: build live tv stream from Paths.getLiveHlsStream?
|
|
||||||
private static func streamURL(
|
|
||||||
item: BaseItemDto,
|
|
||||||
mediaSource: MediaSourceInfo,
|
|
||||||
playSessionID: String,
|
|
||||||
userSession: UserSession,
|
|
||||||
logger: Logger
|
|
||||||
) throws -> URL {
|
|
||||||
|
|
||||||
guard let itemID = item.id else {
|
|
||||||
throw JellyfinAPIError("No item ID while building online media player item!")
|
|
||||||
}
|
|
||||||
|
|
||||||
if let transcodingURL = mediaSource.transcodingURL {
|
|
||||||
logger.trace("Using transcoding URL for item \(itemID)")
|
|
||||||
|
|
||||||
guard let fullTranscodeURL = userSession.client.fullURL(with: transcodingURL)
|
|
||||||
else { throw JellyfinAPIError("Unable to make transcode URL") }
|
|
||||||
return fullTranscodeURL
|
|
||||||
}
|
|
||||||
|
|
||||||
if item.mediaType == .video, !item.isLiveStream {
|
|
||||||
|
|
||||||
logger.trace("Making video stream URL for item \(itemID)")
|
|
||||||
|
|
||||||
let videoStreamParameters = Paths.GetVideoStreamParameters(
|
|
||||||
isStatic: true,
|
|
||||||
tag: item.etag,
|
|
||||||
playSessionID: playSessionID,
|
|
||||||
mediaSourceID: itemID
|
|
||||||
)
|
|
||||||
|
|
||||||
let videoStreamRequest = Paths.getVideoStream(
|
|
||||||
itemID: itemID,
|
|
||||||
parameters: videoStreamParameters
|
|
||||||
)
|
|
||||||
|
|
||||||
guard let videoStreamURL = userSession.client.fullURL(with: videoStreamRequest)
|
|
||||||
else { throw JellyfinAPIError("Unable to make video stream URL") }
|
|
||||||
|
|
||||||
return videoStreamURL
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.trace("Using media source path for item \(itemID)")
|
|
||||||
|
|
||||||
guard let path = mediaSource.path, let streamURL = URL(
|
|
||||||
string: path
|
|
||||||
) else { throw JellyfinAPIError("Unable to make stream URL") }
|
|
||||||
|
|
||||||
return streamURL
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,102 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import JellyfinAPI
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
// TODO: get preview image for current manager seconds?
|
|
||||||
// - would make scrubbing image possibly ready before scrubbing
|
|
||||||
// TODO: fix leaks
|
|
||||||
// - made from publishers of observers not being cancelled
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class MediaPlayerItem: ViewModel, MediaPlayerObserver {
|
|
||||||
|
|
||||||
typealias ThumbnailProvider = () async -> UIImage?
|
|
||||||
|
|
||||||
@Published
|
|
||||||
var selectedAudioStreamIndex: Int? = nil {
|
|
||||||
didSet {
|
|
||||||
if let proxy = manager?.proxy as? any VideoMediaPlayerProxy {
|
|
||||||
proxy.setAudioStream(.init(index: selectedAudioStreamIndex))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Published
|
|
||||||
var selectedSubtitleStreamIndex: Int? = nil {
|
|
||||||
didSet {
|
|
||||||
if let proxy = manager?.proxy as? any VideoMediaPlayerProxy {
|
|
||||||
proxy.setSubtitleStream(.init(index: selectedSubtitleStreamIndex))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
weak var manager: MediaPlayerManager? {
|
|
||||||
didSet {
|
|
||||||
for var o in observers {
|
|
||||||
o.manager = manager
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var observers: [any MediaPlayerObserver] = []
|
|
||||||
|
|
||||||
let baseItem: BaseItemDto
|
|
||||||
let mediaSource: MediaSourceInfo
|
|
||||||
let playSessionID: String
|
|
||||||
let previewImageProvider: (any PreviewImageProvider)?
|
|
||||||
let thumbnailProvider: ThumbnailProvider?
|
|
||||||
let url: URL
|
|
||||||
|
|
||||||
let audioStreams: [MediaStream]
|
|
||||||
let subtitleStreams: [MediaStream]
|
|
||||||
let videoStreams: [MediaStream]
|
|
||||||
|
|
||||||
let requestedBitrate: PlaybackBitrate
|
|
||||||
|
|
||||||
// MARK: init
|
|
||||||
|
|
||||||
init(
|
|
||||||
baseItem: BaseItemDto,
|
|
||||||
mediaSource: MediaSourceInfo,
|
|
||||||
playSessionID: String,
|
|
||||||
url: URL,
|
|
||||||
requestedBitrate: PlaybackBitrate = .max,
|
|
||||||
previewImageProvider: (any PreviewImageProvider)? = nil,
|
|
||||||
thumbnailProvider: ThumbnailProvider? = nil
|
|
||||||
) {
|
|
||||||
self.baseItem = baseItem
|
|
||||||
self.mediaSource = mediaSource
|
|
||||||
self.playSessionID = playSessionID
|
|
||||||
self.requestedBitrate = requestedBitrate
|
|
||||||
self.previewImageProvider = previewImageProvider
|
|
||||||
self.thumbnailProvider = thumbnailProvider
|
|
||||||
self.url = url
|
|
||||||
|
|
||||||
let adjustedMediaStreams = mediaSource.mediaStreams?.adjustedTrackIndexes(
|
|
||||||
for: mediaSource.transcodingURL == nil ? .directPlay : .transcode,
|
|
||||||
selectedAudioStreamIndex: mediaSource.defaultAudioStreamIndex ?? 0
|
|
||||||
)
|
|
||||||
|
|
||||||
let audioStreams = adjustedMediaStreams?.filter { $0.type == .audio } ?? []
|
|
||||||
let subtitleStreams = adjustedMediaStreams?.filter { $0.type == .subtitle } ?? []
|
|
||||||
let videoStreams = adjustedMediaStreams?.filter { $0.type == .video } ?? []
|
|
||||||
|
|
||||||
self.audioStreams = audioStreams
|
|
||||||
self.subtitleStreams = subtitleStreams
|
|
||||||
self.videoStreams = videoStreams
|
|
||||||
|
|
||||||
super.init()
|
|
||||||
|
|
||||||
selectedAudioStreamIndex = mediaSource.defaultAudioStreamIndex ?? -1
|
|
||||||
selectedSubtitleStreamIndex = mediaSource.defaultSubtitleStreamIndex ?? -1
|
|
||||||
|
|
||||||
observers.append(MediaProgressObserver(item: self))
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,240 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import AVFoundation
|
|
||||||
import Combine
|
|
||||||
import Defaults
|
|
||||||
import Foundation
|
|
||||||
import JellyfinAPI
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
// TODO: After NativeVideoPlayer is removed, can move bindings and
|
|
||||||
// observers to AVPlayerView, like the VLC delegate
|
|
||||||
// - wouldn't need to have MediaPlayerProxy: MediaPlayerObserver
|
|
||||||
// TODO: report playback information, see VLCUI.PlaybackInformation (dropped frames, etc.)
|
|
||||||
// TODO: report buffering state
|
|
||||||
// TODO: have set seconds with completion handler
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class AVMediaPlayerProxy: VideoMediaPlayerProxy {
|
|
||||||
|
|
||||||
let isBuffering: PublishedBox<Bool> = .init(initialValue: false)
|
|
||||||
var isScrubbing: Binding<Bool> = .constant(false)
|
|
||||||
var scrubbedSeconds: Binding<Duration> = .constant(.zero)
|
|
||||||
var videoSize: PublishedBox<CGSize> = .init(initialValue: .zero)
|
|
||||||
|
|
||||||
let avPlayerLayer: AVPlayerLayer
|
|
||||||
let player: AVPlayer
|
|
||||||
|
|
||||||
// private var rateObserver: NSKeyValueObservation!
|
|
||||||
private var statusObserver: NSKeyValueObservation!
|
|
||||||
private var timeControlStatusObserver: NSKeyValueObservation!
|
|
||||||
private var timeObserver: Any!
|
|
||||||
private var managerItemObserver: AnyCancellable?
|
|
||||||
private var managerStateObserver: AnyCancellable?
|
|
||||||
|
|
||||||
weak var manager: MediaPlayerManager? {
|
|
||||||
didSet {
|
|
||||||
if let manager {
|
|
||||||
managerItemObserver = manager.$playbackItem
|
|
||||||
.sink { playbackItem in
|
|
||||||
if let playbackItem {
|
|
||||||
self.playNew(item: playbackItem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
managerStateObserver = manager.$state
|
|
||||||
.sink { state in
|
|
||||||
switch state {
|
|
||||||
case .stopped:
|
|
||||||
self.playbackStopped()
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
managerItemObserver?.cancel()
|
|
||||||
managerStateObserver?.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
self.player = AVPlayer()
|
|
||||||
self.avPlayerLayer = AVPlayerLayer(player: player)
|
|
||||||
|
|
||||||
timeObserver = player.addPeriodicTimeObserver(
|
|
||||||
forInterval: CMTime(seconds: 1, preferredTimescale: 1000),
|
|
||||||
queue: .main
|
|
||||||
) { newTime in
|
|
||||||
let newSeconds = Duration.seconds(newTime.seconds)
|
|
||||||
|
|
||||||
if !self.isScrubbing.wrappedValue {
|
|
||||||
self.scrubbedSeconds.wrappedValue = newSeconds
|
|
||||||
}
|
|
||||||
|
|
||||||
self.manager?.seconds = newSeconds
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func play() {
|
|
||||||
player.play()
|
|
||||||
}
|
|
||||||
|
|
||||||
func pause() {
|
|
||||||
player.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() {
|
|
||||||
player.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
func jumpForward(_ seconds: Duration) {
|
|
||||||
let currentTime = player.currentTime()
|
|
||||||
let newTime = currentTime + CMTime(seconds: seconds.seconds, preferredTimescale: 1)
|
|
||||||
player.seek(to: newTime, toleranceBefore: .zero, toleranceAfter: .zero)
|
|
||||||
}
|
|
||||||
|
|
||||||
func jumpBackward(_ seconds: Duration) {
|
|
||||||
let currentTime = player.currentTime()
|
|
||||||
let newTime = max(.zero, currentTime - CMTime(seconds: seconds.seconds, preferredTimescale: 1))
|
|
||||||
player.seek(to: newTime, toleranceBefore: .zero, toleranceAfter: .zero)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setSeconds(_ seconds: Duration) {
|
|
||||||
let time = CMTime(seconds: seconds.seconds, preferredTimescale: 1)
|
|
||||||
player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: complete
|
|
||||||
func setRate(_ rate: Float) {}
|
|
||||||
func setAudioStream(_ stream: MediaStream) {}
|
|
||||||
func setSubtitleStream(_ stream: MediaStream) {}
|
|
||||||
|
|
||||||
func setAspectFill(_ aspectFill: Bool) {
|
|
||||||
avPlayerLayer.videoGravity = aspectFill ? .resizeAspectFill : .resizeAspect
|
|
||||||
}
|
|
||||||
|
|
||||||
var videoPlayerBody: some View {
|
|
||||||
AVPlayerView()
|
|
||||||
.environmentObject(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AVMediaPlayerProxy {
|
|
||||||
|
|
||||||
private func playbackStopped() {
|
|
||||||
player.pause()
|
|
||||||
guard let timeObserver else { return }
|
|
||||||
player.removeTimeObserver(timeObserver)
|
|
||||||
// rateObserver.invalidate()
|
|
||||||
statusObserver.invalidate()
|
|
||||||
timeControlStatusObserver.invalidate()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func playNew(item: MediaPlayerItem) {
|
|
||||||
let baseItem = item.baseItem
|
|
||||||
|
|
||||||
let newAVPlayerItem = AVPlayerItem(url: item.url)
|
|
||||||
newAVPlayerItem.externalMetadata = item.baseItem.avMetadata
|
|
||||||
|
|
||||||
player.replaceCurrentItem(with: newAVPlayerItem)
|
|
||||||
|
|
||||||
// TODO: protect against paused
|
|
||||||
// rateObserver = player.observe(\.rate, options: [.new, .initial]) { _, value in
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// self.manager?.set(rate: value.newValue ?? 1.0)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
timeControlStatusObserver = player.observe(\.timeControlStatus, options: [.new, .initial]) { player, _ in
|
|
||||||
let timeControlStatus = player.timeControlStatus
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
switch timeControlStatus {
|
|
||||||
case .paused:
|
|
||||||
self.manager?.setPlaybackRequestStatus(status: .paused)
|
|
||||||
case .waitingToPlayAtSpecifiedRate: ()
|
|
||||||
// TODO: buffering
|
|
||||||
case .playing:
|
|
||||||
self.manager?.setPlaybackRequestStatus(status: .playing)
|
|
||||||
@unknown default: ()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: proper handling of none/unknown states
|
|
||||||
statusObserver = player.observe(\.currentItem?.status, options: [.new, .initial]) { _, value in
|
|
||||||
guard let newValue = value.newValue else { return }
|
|
||||||
switch newValue {
|
|
||||||
case .failed:
|
|
||||||
if let error = self.player.error {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.manager?.error(JellyfinAPIError("AVPlayer error: \(error.localizedDescription)"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case .none, .readyToPlay, .unknown:
|
|
||||||
let startSeconds = max(.zero, (baseItem.startSeconds ?? .zero) - Duration.seconds(Defaults[.VideoPlayer.resumeOffset]))
|
|
||||||
|
|
||||||
self.player.seek(
|
|
||||||
to: CMTimeMake(
|
|
||||||
value: startSeconds.components.seconds,
|
|
||||||
timescale: 1
|
|
||||||
),
|
|
||||||
toleranceBefore: .zero,
|
|
||||||
toleranceAfter: .zero,
|
|
||||||
completionHandler: { _ in
|
|
||||||
self.play()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@unknown default: ()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - AVPlayerView
|
|
||||||
|
|
||||||
extension AVMediaPlayerProxy {
|
|
||||||
|
|
||||||
struct AVPlayerView: UIViewRepresentable {
|
|
||||||
|
|
||||||
@EnvironmentObject
|
|
||||||
private var proxy: AVMediaPlayerProxy
|
|
||||||
@EnvironmentObject
|
|
||||||
private var scrubbedSeconds: PublishedBox<Duration>
|
|
||||||
|
|
||||||
func makeUIView(context: Context) -> UIView {
|
|
||||||
// proxy.isScrubbing = context.environment.isScrubbing
|
|
||||||
// proxy.scrubbedSeconds = $scrubbedSeconds.value
|
|
||||||
UIAVPlayerView(proxy: proxy)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIView(_ uiView: UIView, context: Context) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class UIAVPlayerView: UIView {
|
|
||||||
|
|
||||||
let proxy: AVMediaPlayerProxy
|
|
||||||
|
|
||||||
init(proxy: AVMediaPlayerProxy) {
|
|
||||||
self.proxy = proxy
|
|
||||||
super.init(frame: .zero)
|
|
||||||
layer.addSublayer(proxy.avPlayerLayer)
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func layoutSubviews() {
|
|
||||||
super.layoutSubviews()
|
|
||||||
proxy.avPlayerLayer.frame = bounds
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,230 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import Defaults
|
|
||||||
import Foundation
|
|
||||||
import JellyfinAPI
|
|
||||||
import SwiftUI
|
|
||||||
import VLCUI
|
|
||||||
|
|
||||||
class VLCMediaPlayerProxy: VideoMediaPlayerProxy,
|
|
||||||
MediaPlayerOffsetConfigurable,
|
|
||||||
MediaPlayerSubtitleConfigurable
|
|
||||||
{
|
|
||||||
|
|
||||||
let isBuffering: PublishedBox<Bool> = .init(initialValue: false)
|
|
||||||
let videoSize: PublishedBox<CGSize> = .init(initialValue: .zero)
|
|
||||||
let vlcUIProxy: VLCVideoPlayer.Proxy = .init()
|
|
||||||
|
|
||||||
weak var manager: MediaPlayerManager? {
|
|
||||||
didSet {
|
|
||||||
for var o in observers {
|
|
||||||
o.manager = manager
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var observers: [any MediaPlayerObserver] = [
|
|
||||||
NowPlayableObserver(),
|
|
||||||
]
|
|
||||||
|
|
||||||
func play() {
|
|
||||||
vlcUIProxy.play()
|
|
||||||
}
|
|
||||||
|
|
||||||
func pause() {
|
|
||||||
vlcUIProxy.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() {
|
|
||||||
vlcUIProxy.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
func jumpForward(_ seconds: Duration) {
|
|
||||||
vlcUIProxy.jumpForward(seconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
func jumpBackward(_ seconds: Duration) {
|
|
||||||
vlcUIProxy.jumpBackward(seconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setRate(_ rate: Float) {
|
|
||||||
vlcUIProxy.setRate(.absolute(rate))
|
|
||||||
}
|
|
||||||
|
|
||||||
func setSeconds(_ seconds: Duration) {
|
|
||||||
vlcUIProxy.setSeconds(seconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setAudioStream(_ stream: MediaStream) {
|
|
||||||
vlcUIProxy.setAudioTrack(.absolute(stream.index ?? -1))
|
|
||||||
}
|
|
||||||
|
|
||||||
func setSubtitleStream(_ stream: MediaStream) {
|
|
||||||
vlcUIProxy.setSubtitleTrack(.absolute(stream.index ?? -1))
|
|
||||||
}
|
|
||||||
|
|
||||||
func setAspectFill(_ aspectFill: Bool) {
|
|
||||||
vlcUIProxy.aspectFill(aspectFill ? 1 : 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setAudioOffset(_ seconds: Duration) {
|
|
||||||
vlcUIProxy.setAudioDelay(seconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setSubtitleOffset(_ seconds: Duration) {
|
|
||||||
vlcUIProxy.setSubtitleDelay(seconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setSubtitleColor(_ color: Color) {
|
|
||||||
vlcUIProxy.setSubtitleColor(.absolute(color.uiColor))
|
|
||||||
}
|
|
||||||
|
|
||||||
func setSubtitleFontName(_ fontName: String) {
|
|
||||||
vlcUIProxy.setSubtitleFont(fontName)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setSubtitleFontSize(_ fontSize: Int) {
|
|
||||||
vlcUIProxy.setSubtitleSize(.absolute(fontSize))
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
var videoPlayerBody: some View {
|
|
||||||
VLCPlayerView()
|
|
||||||
.environmentObject(vlcUIProxy)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension VLCMediaPlayerProxy {
|
|
||||||
|
|
||||||
struct VLCPlayerView: View {
|
|
||||||
|
|
||||||
@Default(.VideoPlayer.Subtitle.subtitleColor)
|
|
||||||
private var subtitleColor
|
|
||||||
@Default(.VideoPlayer.Subtitle.subtitleFontName)
|
|
||||||
private var subtitleFontName
|
|
||||||
@Default(.VideoPlayer.Subtitle.subtitleSize)
|
|
||||||
private var subtitleSize
|
|
||||||
|
|
||||||
@EnvironmentObject
|
|
||||||
private var containerState: VideoPlayerContainerState
|
|
||||||
@EnvironmentObject
|
|
||||||
private var manager: MediaPlayerManager
|
|
||||||
@EnvironmentObject
|
|
||||||
private var proxy: VLCVideoPlayer.Proxy
|
|
||||||
|
|
||||||
private var isScrubbing: Bool {
|
|
||||||
containerState.isScrubbing
|
|
||||||
}
|
|
||||||
|
|
||||||
private func vlcConfiguration(for item: MediaPlayerItem) -> VLCVideoPlayer.Configuration {
|
|
||||||
let baseItem = item.baseItem
|
|
||||||
let mediaSource = item.mediaSource
|
|
||||||
|
|
||||||
var configuration = VLCVideoPlayer.Configuration(url: item.url)
|
|
||||||
configuration.autoPlay = true
|
|
||||||
|
|
||||||
let startSeconds = max(.zero, (baseItem.startSeconds ?? .zero) - Duration.seconds(Defaults[.VideoPlayer.resumeOffset]))
|
|
||||||
|
|
||||||
if !baseItem.isLiveStream {
|
|
||||||
configuration.startSeconds = startSeconds
|
|
||||||
configuration.audioIndex = .absolute(mediaSource.defaultAudioStreamIndex ?? -1)
|
|
||||||
configuration.subtitleIndex = .absolute(mediaSource.defaultSubtitleStreamIndex ?? -1)
|
|
||||||
}
|
|
||||||
|
|
||||||
configuration.subtitleSize = .absolute(25 - Defaults[.VideoPlayer.Subtitle.subtitleSize])
|
|
||||||
configuration.subtitleColor = .absolute(Defaults[.VideoPlayer.Subtitle.subtitleColor].uiColor)
|
|
||||||
|
|
||||||
if let font = UIFont(name: Defaults[.VideoPlayer.Subtitle.subtitleFontName], size: 1) {
|
|
||||||
configuration.subtitleFont = .absolute(font)
|
|
||||||
}
|
|
||||||
|
|
||||||
configuration.playbackChildren = item.subtitleStreams
|
|
||||||
.filter { $0.deliveryMethod == .external }
|
|
||||||
.compactMap(\.asVLCPlaybackChild)
|
|
||||||
|
|
||||||
return configuration
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if let playbackItem = manager.playbackItem, manager.state != .stopped {
|
|
||||||
VLCVideoPlayer(configuration: vlcConfiguration(for: playbackItem))
|
|
||||||
.proxy(proxy)
|
|
||||||
.onSecondsUpdated { newSeconds, info in
|
|
||||||
if !isScrubbing {
|
|
||||||
containerState.scrubbedSeconds.value = newSeconds
|
|
||||||
}
|
|
||||||
|
|
||||||
manager.seconds = newSeconds
|
|
||||||
|
|
||||||
if let proxy = manager.proxy as? any VideoMediaPlayerProxy {
|
|
||||||
proxy.videoSize.value = info.videoSize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onStateUpdated { state, info in
|
|
||||||
manager.logger.trace("VLC state updated: \(state)")
|
|
||||||
|
|
||||||
switch state {
|
|
||||||
case .buffering,
|
|
||||||
.esAdded,
|
|
||||||
.opening:
|
|
||||||
// TODO: figure out when to properly set to false
|
|
||||||
manager.proxy?.isBuffering.value = true
|
|
||||||
case .ended:
|
|
||||||
// Live streams will send stopped/ended events
|
|
||||||
guard !playbackItem.baseItem.isLiveStream else { return }
|
|
||||||
manager.proxy?.isBuffering.value = false
|
|
||||||
manager.ended()
|
|
||||||
case .stopped: ()
|
|
||||||
// Stopped is ignored as the `MediaPlayerManager`
|
|
||||||
// should instead call this to be stopped, rather
|
|
||||||
// than react to the event.
|
|
||||||
case .error:
|
|
||||||
manager.proxy?.isBuffering.value = false
|
|
||||||
manager.error(JellyfinAPIError("VLC player is unable to perform playback"))
|
|
||||||
case .playing:
|
|
||||||
manager.proxy?.isBuffering.value = false
|
|
||||||
manager.setPlaybackRequestStatus(status: .playing)
|
|
||||||
case .paused:
|
|
||||||
manager.setPlaybackRequestStatus(status: .paused)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let proxy = manager.proxy as? any VideoMediaPlayerProxy {
|
|
||||||
proxy.videoSize.value = info.videoSize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onReceive(manager.$playbackItem) { playbackItem in
|
|
||||||
guard let playbackItem else { return }
|
|
||||||
proxy.playNewMedia(vlcConfiguration(for: playbackItem))
|
|
||||||
}
|
|
||||||
.backport
|
|
||||||
.onChange(of: manager.rate) { _, newValue in
|
|
||||||
proxy.setRate(.absolute(newValue))
|
|
||||||
}
|
|
||||||
.backport
|
|
||||||
.onChange(of: subtitleColor) { _, newValue in
|
|
||||||
if let proxy = proxy as? MediaPlayerSubtitleConfigurable {
|
|
||||||
proxy.setSubtitleColor(newValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.backport
|
|
||||||
.onChange(of: subtitleFontName) { _, newValue in
|
|
||||||
if let proxy = proxy as? MediaPlayerSubtitleConfigurable {
|
|
||||||
proxy.setSubtitleFontName(newValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.backport
|
|
||||||
.onChange(of: subtitleSize) { _, newValue in
|
|
||||||
if let proxy = proxy as? MediaPlayerSubtitleConfigurable {
|
|
||||||
proxy.setSubtitleFontSize(25 - newValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,59 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import JellyfinAPI
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
// TODO: feature implementations
|
|
||||||
// - PiP
|
|
||||||
// TODO: Chromecast proxy
|
|
||||||
|
|
||||||
/// The proxy for top-down communication to an
|
|
||||||
/// underlying media player
|
|
||||||
protocol MediaPlayerProxy: ObservableObject, MediaPlayerObserver {
|
|
||||||
|
|
||||||
var isBuffering: PublishedBox<Bool> { get }
|
|
||||||
|
|
||||||
func play()
|
|
||||||
func pause()
|
|
||||||
func stop()
|
|
||||||
|
|
||||||
func jumpForward(_ seconds: Duration)
|
|
||||||
func jumpBackward(_ seconds: Duration)
|
|
||||||
func setRate(_ rate: Float)
|
|
||||||
func setSeconds(_ seconds: Duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
protocol VideoMediaPlayerProxy: MediaPlayerProxy {
|
|
||||||
|
|
||||||
associatedtype VideoPlayerBody: View
|
|
||||||
|
|
||||||
var videoSize: PublishedBox<CGSize> { get }
|
|
||||||
|
|
||||||
// TODO: remove when container view handles aspect fill
|
|
||||||
func setAspectFill(_ aspectFill: Bool)
|
|
||||||
func setAudioStream(_ stream: MediaStream)
|
|
||||||
func setSubtitleStream(_ stream: MediaStream)
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
@MainActor
|
|
||||||
var videoPlayerBody: Self.VideoPlayerBody { get }
|
|
||||||
}
|
|
||||||
|
|
||||||
protocol MediaPlayerOffsetConfigurable {
|
|
||||||
func setAudioOffset(_ seconds: Duration)
|
|
||||||
func setSubtitleOffset(_ seconds: Duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
protocol MediaPlayerSubtitleConfigurable {
|
|
||||||
func setSubtitleColor(_ color: Color)
|
|
||||||
func setSubtitleFontName(_ fontName: String)
|
|
||||||
func setSubtitleFontSize(_ fontSize: Int)
|
|
||||||
}
|
|
|
@ -1,115 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import MediaPlayer
|
|
||||||
|
|
||||||
enum NowPlayableCommand: CaseIterable {
|
|
||||||
|
|
||||||
// Play/Pause
|
|
||||||
case pause
|
|
||||||
case play
|
|
||||||
case stop
|
|
||||||
case togglePausePlay
|
|
||||||
|
|
||||||
// Track
|
|
||||||
case nextTrack
|
|
||||||
case previousTrack
|
|
||||||
case changeRepeatMode
|
|
||||||
case changeShuffleMode
|
|
||||||
|
|
||||||
// Seeking/Rate
|
|
||||||
case changePlaybackRate
|
|
||||||
case seekBackward
|
|
||||||
case seekForward
|
|
||||||
case skipBackward
|
|
||||||
case skipForward
|
|
||||||
case changePlaybackPosition
|
|
||||||
|
|
||||||
// Like/Dislike
|
|
||||||
case rating
|
|
||||||
case like
|
|
||||||
case dislike
|
|
||||||
|
|
||||||
// Bookmark
|
|
||||||
case bookmark
|
|
||||||
|
|
||||||
// Languages
|
|
||||||
case enableLanguageOption
|
|
||||||
case disableLanguageOption
|
|
||||||
|
|
||||||
var remoteCommand: MPRemoteCommand {
|
|
||||||
let remoteCommandCenter = MPRemoteCommandCenter.shared()
|
|
||||||
|
|
||||||
switch self {
|
|
||||||
case .pause:
|
|
||||||
return remoteCommandCenter.pauseCommand
|
|
||||||
case .play:
|
|
||||||
return remoteCommandCenter.playCommand
|
|
||||||
case .stop:
|
|
||||||
return remoteCommandCenter.stopCommand
|
|
||||||
case .togglePausePlay:
|
|
||||||
return remoteCommandCenter.togglePlayPauseCommand
|
|
||||||
case .nextTrack:
|
|
||||||
return remoteCommandCenter.nextTrackCommand
|
|
||||||
case .previousTrack:
|
|
||||||
return remoteCommandCenter.previousTrackCommand
|
|
||||||
case .changeRepeatMode:
|
|
||||||
return remoteCommandCenter.changeRepeatModeCommand
|
|
||||||
case .changeShuffleMode:
|
|
||||||
return remoteCommandCenter.changeShuffleModeCommand
|
|
||||||
case .changePlaybackRate:
|
|
||||||
return remoteCommandCenter.changePlaybackRateCommand
|
|
||||||
case .seekBackward:
|
|
||||||
return remoteCommandCenter.seekBackwardCommand
|
|
||||||
case .seekForward:
|
|
||||||
return remoteCommandCenter.seekForwardCommand
|
|
||||||
case .skipBackward:
|
|
||||||
return remoteCommandCenter.skipBackwardCommand
|
|
||||||
case .skipForward:
|
|
||||||
return remoteCommandCenter.skipForwardCommand
|
|
||||||
case .changePlaybackPosition:
|
|
||||||
return remoteCommandCenter.changePlaybackPositionCommand
|
|
||||||
case .rating:
|
|
||||||
return remoteCommandCenter.ratingCommand
|
|
||||||
case .like:
|
|
||||||
return remoteCommandCenter.likeCommand
|
|
||||||
case .dislike:
|
|
||||||
return remoteCommandCenter.dislikeCommand
|
|
||||||
case .bookmark:
|
|
||||||
return remoteCommandCenter.bookmarkCommand
|
|
||||||
case .enableLanguageOption:
|
|
||||||
return remoteCommandCenter.enableLanguageOptionCommand
|
|
||||||
case .disableLanguageOption:
|
|
||||||
return remoteCommandCenter.disableLanguageOptionCommand
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeHandler() {
|
|
||||||
remoteCommand.removeTarget(nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func addHandler(_ handler: @escaping (NowPlayableCommand, MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus) {
|
|
||||||
|
|
||||||
remoteCommand.removeTarget(nil)
|
|
||||||
|
|
||||||
switch self {
|
|
||||||
case .skipBackward:
|
|
||||||
MPRemoteCommandCenter.shared().skipBackwardCommand.preferredIntervals = [15.0]
|
|
||||||
case .skipForward:
|
|
||||||
MPRemoteCommandCenter.shared().skipForwardCommand.preferredIntervals = [15.0]
|
|
||||||
default: ()
|
|
||||||
}
|
|
||||||
|
|
||||||
remoteCommand.addTarget { handler(self, $0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
func isEnabled(_ isEnabled: Bool) {
|
|
||||||
remoteCommand.isEnabled = isEnabled
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,65 +0,0 @@
|
||||||
//
|
|
||||||
// Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import MediaPlayer
|
|
||||||
|
|
||||||
struct NowPlayableStaticMetadata {
|
|
||||||
|
|
||||||
let mediaType: MPNowPlayingInfoMediaType
|
|
||||||
let isLiveStream: Bool
|
|
||||||
|
|
||||||
let title: String
|
|
||||||
let artist: String?
|
|
||||||
let artwork: MPMediaItemArtwork?
|
|
||||||
|
|
||||||
let albumArtist: String?
|
|
||||||
let albumTitle: String?
|
|
||||||
|
|
||||||
init(
|
|
||||||
mediaType: MPNowPlayingInfoMediaType,
|
|
||||||
isLiveStream: Bool = false,
|
|
||||||
title: String,
|
|
||||||
artist: String? = nil,
|
|
||||||
artwork: MPMediaItemArtwork? = nil,
|
|
||||||
albumArtist: String? = nil,
|
|
||||||
albumTitle: String? = nil
|
|
||||||
) {
|
|
||||||
self.mediaType = mediaType
|
|
||||||
self.isLiveStream = isLiveStream
|
|
||||||
self.title = title
|
|
||||||
self.artist = artist
|
|
||||||
self.artwork = artwork
|
|
||||||
self.albumArtist = albumArtist
|
|
||||||
self.albumTitle = albumTitle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct NowPlayableDynamicMetadata {
|
|
||||||
|
|
||||||
let rate: Float
|
|
||||||
let position: Duration
|
|
||||||
let duration: Duration
|
|
||||||
|
|
||||||
let currentLanguageOptions: [MPNowPlayingInfoLanguageOption]
|
|
||||||
let availableLanguageOptionGroups: [MPNowPlayingInfoLanguageOptionGroup]
|
|
||||||
|
|
||||||
init(
|
|
||||||
rate: Float = 1,
|
|
||||||
position: Duration,
|
|
||||||
duration: Duration,
|
|
||||||
currentLanguageOptions: [MPNowPlayingInfoLanguageOption] = [],
|
|
||||||
availableLanguageOptionGroups: [MPNowPlayingInfoLanguageOptionGroup] = []
|
|
||||||
) {
|
|
||||||
self.rate = rate
|
|
||||||
self.position = position
|
|
||||||
self.duration = duration
|
|
||||||
self.currentLanguageOptions = currentLanguageOptions
|
|
||||||
self.availableLanguageOptionGroups = availableLanguageOptionGroups
|
|
||||||
}
|
|
||||||
}
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue