Compare commits
10 Commits
54154b032f
...
09a3ce15a0
Author | SHA1 | Date |
---|---|---|
|
09a3ce15a0 | |
|
fdd1cdc15b | |
|
de349e213e | |
|
75d5ae890c | |
|
f19c10c528 | |
|
a6a03ea988 | |
|
0dcde5dd74 | |
|
7c28c04048 | |
|
d0a921d05c | |
|
8ad851ead1 |
|
@ -0,0 +1,44 @@
|
||||||
|
---
|
||||||
|
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`
|
|
@ -0,0 +1,17 @@
|
||||||
|
---
|
||||||
|
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.
|
|
@ -0,0 +1,41 @@
|
||||||
|
---
|
||||||
|
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.
|
|
@ -0,0 +1,107 @@
|
||||||
|
# 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
|
|
@ -0,0 +1,364 @@
|
||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,131 @@
|
||||||
|
//
|
||||||
|
// 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]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,153 @@
|
||||||
|
//
|
||||||
|
// 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]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,120 @@
|
||||||
|
//
|
||||||
|
// 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]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,91 @@
|
||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,111 @@
|
||||||
|
//
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,90 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
//
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,72 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
|
@ -0,0 +1,72 @@
|
||||||
|
//
|
||||||
|
// 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(.modal)
|
@Route(.push)
|
||||||
var log = makeLog
|
var log = makeLog
|
||||||
|
|
||||||
@Route(.fullScreen)
|
@Route(.fullScreen)
|
||||||
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
//
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,45 @@
|
||||||
|
//
|
||||||
|
// 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(.modal)
|
@Route(.push)
|
||||||
var item = makeItem
|
var item = makeItem
|
||||||
@Route(.modal)
|
@Route(.push)
|
||||||
var library = makeLibrary
|
var library = makeLibrary
|
||||||
#else
|
#else
|
||||||
@Route(.push)
|
@Route(.push)
|
||||||
|
@ -30,15 +30,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
@ -46,7 +37,6 @@ 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 {
|
||||||
|
|
|
@ -0,0 +1,60 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
|
@ -0,0 +1,60 @@
|
||||||
|
//
|
||||||
|
// 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(.modal)
|
@Route(.push)
|
||||||
var item = makeItem
|
var item = makeItem
|
||||||
@Route(.push)
|
@Route(.push)
|
||||||
var library = makeLibrary
|
var library = makeLibrary
|
||||||
|
@ -44,15 +44,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
@ -61,6 +52,7 @@ 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,24 +13,25 @@ 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: makeProgramsTab)
|
@Route(tabItem: makeProgramGuideTab)
|
||||||
var programs = makePrograms
|
var programGuide = makeProgramGuide
|
||||||
|
|
||||||
@Route(tabItem: makeChannelsTab)
|
@Route(tabItem: makeChannelsTab)
|
||||||
var channels = makeChannels
|
var channels = makeChannels
|
||||||
|
|
||||||
func makePrograms() -> VideoPlayerWrapperCoordinator {
|
func makeProgramGuide() -> VideoPlayerWrapperCoordinator {
|
||||||
VideoPlayerWrapperCoordinator {
|
VideoPlayerWrapperCoordinator {
|
||||||
ProgramsView()
|
ProgramGuideView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func makeProgramsTab(isActive: Bool) -> some View {
|
func makeProgramGuideTab(isActive: Bool) -> some View {
|
||||||
Label(L10n.programs, systemImage: "tv")
|
Label("Guide", systemImage: "list.bullet.rectangle")
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeChannels() -> VideoPlayerWrapperCoordinator {
|
func makeChannels() -> VideoPlayerWrapperCoordinator {
|
||||||
|
|
|
@ -69,6 +69,8 @@ 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 {
|
||||||
|
|
|
@ -0,0 +1,56 @@
|
||||||
|
//
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,56 @@
|
||||||
|
//
|
||||||
|
// 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(.push)
|
@Route(.fullScreen)
|
||||||
var library = makeLibrary
|
var library = makeLibrary
|
||||||
@Route(.push)
|
@Route(.fullScreen)
|
||||||
var liveTV = makeLiveTV
|
var liveTV = makeLiveTV
|
||||||
#else
|
#else
|
||||||
@Route(.push)
|
@Route(.push)
|
||||||
|
|
|
@ -0,0 +1,47 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,106 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,226 @@
|
||||||
|
//
|
||||||
|
// 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
|
|
@ -0,0 +1,50 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
|
@ -0,0 +1,275 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
|
@ -0,0 +1,35 @@
|
||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,121 @@
|
||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,205 @@
|
||||||
|
//
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,78 @@
|
||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,87 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,101 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,56 @@
|
||||||
|
//
|
||||||
|
// 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
|
|
@ -0,0 +1,86 @@
|
||||||
|
//
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,52 @@
|
||||||
|
//
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,52 @@
|
||||||
|
//
|
||||||
|
// 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(.modal)
|
@Route(.push)
|
||||||
var item = makeItem
|
var item = makeItem
|
||||||
@Route(.modal)
|
@Route(.push)
|
||||||
var library = makeLibrary
|
var library = makeLibrary
|
||||||
#else
|
#else
|
||||||
@Route(.push)
|
@Route(.push)
|
||||||
|
@ -31,15 +31,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
@ -48,6 +39,7 @@ 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))
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,59 @@
|
||||||
|
//
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,59 @@
|
||||||
|
//
|
||||||
|
// 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,23 +17,36 @@ final class SelectUserCoordinator: NavigationCoordinatable {
|
||||||
@Root
|
@Root
|
||||||
var start = makeStart
|
var start = makeStart
|
||||||
|
|
||||||
@Route(.modal)
|
@Route(.push)
|
||||||
var advancedSettings = makeAdvancedSettings
|
var advancedSettings = makeAdvancedSettings
|
||||||
@Route(.modal)
|
@Route(.push)
|
||||||
var connectToServer = makeConnectToServer
|
var connectToServer = makeConnectToServer
|
||||||
@Route(.modal)
|
@Route(.push)
|
||||||
|
var connectToXtream = makeConnectToXtream
|
||||||
|
@Route(.push)
|
||||||
|
var dualServerConnect = makeDualServerConnect
|
||||||
|
@Route(.push)
|
||||||
var editServer = makeEditServer
|
var editServer = makeEditServer
|
||||||
@Route(.modal)
|
@Route(.push)
|
||||||
var userSignIn = makeUserSignIn
|
var userSignIn = makeUserSignIn
|
||||||
|
|
||||||
func makeAdvancedSettings() -> NavigationViewCoordinator<AppSettingsCoordinator> {
|
func makeAdvancedSettings() -> NavigationViewCoordinator<AppSettingsCoordinator> {
|
||||||
NavigationViewCoordinator(AppSettingsCoordinator())
|
NavigationViewCoordinator(AppSettingsCoordinator())
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeConnectToServer() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
@ViewBuilder
|
||||||
NavigationViewCoordinator {
|
func makeConnectToServer() -> some View {
|
||||||
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> {
|
||||||
|
|
|
@ -0,0 +1,263 @@
|
||||||
|
//
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,263 @@
|
||||||
|
//
|
||||||
|
// 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(.modal)
|
@Route(.push)
|
||||||
var resetUserPassword = makeResetUserPassword
|
var resetUserPassword = makeResetUserPassword
|
||||||
@Route(.push)
|
@Route(.push)
|
||||||
var localSecurity = makeLocalSecurity
|
var localSecurity = makeLocalSecurity
|
||||||
@Route(.modal)
|
@Route(.push)
|
||||||
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(.modal)
|
@Route(.push)
|
||||||
var itemOverviewView = makeItemOverviewView
|
var itemOverviewView = makeItemOverviewView
|
||||||
|
|
||||||
@Route(.modal)
|
@Route(.push)
|
||||||
var editCustomDeviceProfile = makeEditCustomDeviceProfile
|
var editCustomDeviceProfile = makeEditCustomDeviceProfile
|
||||||
@Route(.modal)
|
@Route(.push)
|
||||||
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(.modal)
|
@Route(.push)
|
||||||
var customizeViewsSettings = makeCustomizeViewsSettings
|
var customizeViewsSettings = makeCustomizeViewsSettings
|
||||||
@Route(.modal)
|
@Route(.push)
|
||||||
var experimentalSettings = makeExperimentalSettings
|
var experimentalSettings = makeExperimentalSettings
|
||||||
@Route(.modal)
|
@Route(.push)
|
||||||
var log = makeLog
|
var log = makeLog
|
||||||
@Route(.modal)
|
@Route(.push)
|
||||||
var serverDetail = makeServerDetail
|
var serverDetail = makeServerDetail
|
||||||
@Route(.modal)
|
@Route(.push)
|
||||||
var videoPlayerSettings = makeVideoPlayerSettings
|
var videoPlayerSettings = makeVideoPlayerSettings
|
||||||
@Route(.modal)
|
@Route(.push)
|
||||||
var playbackQualitySettings = makePlaybackQualitySettings
|
var playbackQualitySettings = makePlaybackQualitySettings
|
||||||
@Route(.modal)
|
@Route(.push)
|
||||||
var userProfile = makeUserProfileSettings
|
var userProfile = makeUserProfileSettings
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,69 @@
|
||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,50 @@
|
||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,95 @@
|
||||||
|
//
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
//
|
||||||
|
// 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()
|
||||||
|
}
|
|
@ -0,0 +1,61 @@
|
||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,61 @@
|
||||||
|
//
|
||||||
|
// 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(.modal)
|
@Route(.push)
|
||||||
var quickConnect = makeQuickConnect
|
var quickConnect = makeQuickConnect
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@Route(.modal)
|
@Route(.push)
|
||||||
var security = makeSecurity
|
var security = makeSecurity
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
//
|
||||||
|
// 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
//
|
||||||
|
// 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?
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
//
|
||||||
|
// 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,6 +105,13 @@ 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!
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
//
|
||||||
|
// 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,34 +72,91 @@ extension MediaSourceInfo {
|
||||||
let playbackURL: URL
|
let playbackURL: URL
|
||||||
let playMethod: PlayMethod
|
let playMethod: PlayMethod
|
||||||
|
|
||||||
if let transcodingURL {
|
print("🎬 liveVideoPlayerViewModel: Starting for item \(item.displayTitle)")
|
||||||
guard let fullTranscodeURL = URL(string: transcodingURL, relativeTo: userSession.server.currentURL)
|
print("🎬 Server URL: \(userSession.server.currentURL)")
|
||||||
else { throw JellyfinAPIError("Unable to construct transcoded url") }
|
print("🎬 TranscodingURL: \(transcodingURL ?? "nil")")
|
||||||
|
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
|
||||||
} else if self.isSupportsDirectPlay ?? false, let path = self.path, let playbackUrl = URL(string: path) {
|
print("🎬 Using live transcoding URL (converted from master): \(playbackURL)")
|
||||||
|
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 {
|
||||||
let videoStreamParameters = Paths.GetVideoStreamParameters(
|
// Use Jellyfin's live.m3u8 endpoint for Live TV (same as web browser)
|
||||||
isStatic: true,
|
// Construct URL: /videos/{id}/live.m3u8?DeviceId=...&MediaSourceId=...&PlaySessionId=...&api_key=...
|
||||||
tag: item.etag,
|
let deviceId = userSession.client.configuration.deviceID ?? "unknown"
|
||||||
playSessionID: playSessionID,
|
let apiKey = userSession.client.accessToken ?? ""
|
||||||
mediaSourceID: id
|
|
||||||
)
|
|
||||||
|
|
||||||
let videoStreamRequest = Paths.getVideoStream(
|
var urlComponents = URLComponents()
|
||||||
itemID: item.id!,
|
urlComponents.scheme = userSession.server.currentURL.scheme
|
||||||
parameters: videoStreamParameters
|
urlComponents.host = userSession.server.currentURL.host
|
||||||
)
|
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 fullURL = userSession.client.fullURL(with: videoStreamRequest) else {
|
guard let liveURL = urlComponents.url else {
|
||||||
throw JellyfinAPIError("Unable to construct transcoded url")
|
print("🎬 ERROR: Unable to construct live.m3u8 URL")
|
||||||
|
throw JellyfinAPIError("Unable to construct live.m3u8 URL")
|
||||||
}
|
}
|
||||||
playbackURL = fullURL
|
playbackURL = liveURL
|
||||||
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 } ?? []
|
||||||
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
//
|
||||||
|
// 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: "org.ashik.jellypig/Posters") { name in
|
let dataCache = try? DataCache(name: "se.ashik.jellyflood/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/org.ashik.jellypig.local", isDirectory: true)
|
let path = root.appendingPathComponent("Caches/se.ashik.jellyflood.local", isDirectory: true)
|
||||||
|
|
||||||
let dataCache = try? DataCache(path: path) { name in
|
let dataCache = try? DataCache(path: path) { name in
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,60 @@
|
||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,60 @@
|
||||||
|
//
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,81 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,217 @@
|
||||||
|
//
|
||||||
|
// 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
|
|
@ -0,0 +1,73 @@
|
||||||
|
//
|
||||||
|
// 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 "💥"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,57 @@
|
||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,84 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
//
|
||||||
|
// 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,4 +53,20 @@ 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,44 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
//
|
||||||
|
// 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 {}
|
|
@ -0,0 +1,22 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,54 @@
|
||||||
|
//
|
||||||
|
// 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()))
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,83 @@
|
||||||
|
//
|
||||||
|
// 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,218 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,102 @@
|
||||||
|
//
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,240 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,230 @@
|
||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,59 @@
|
||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
|
@ -0,0 +1,115 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,65 @@
|
||||||
|
//
|
||||||
|
// 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