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