Rename project from jellypig to jellyflood
Complete rebranding from jellypig to jellyflood including: - Renamed all jellypig references to jellyflood - Updated store implementations (jellypigstore -> jellyfloodstore) - Moved jellypig tvOS to Swiftfin tvOS structure - Updated service configurations and defaults - Preserved all Xtream plugin support and EPG functionality 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
fdd1cdc15b
commit
09a3ce15a0
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
description: Build jellypig tvOS (debug or release)
|
||||
---
|
||||
|
||||
Build jellypig tvOS for the simulator. Takes an optional configuration argument:
|
||||
- `debug` (default) - Fast build with debugging symbols
|
||||
- `release` - Optimized build for distribution
|
||||
|
||||
Usage:
|
||||
- `/build` - Build in Debug configuration (default)
|
||||
- `/build debug` - Build in Debug configuration (explicit)
|
||||
- `/build release` - Build in Release configuration
|
||||
|
||||
Steps to execute:
|
||||
1. Parse the configuration argument (default to "debug" if not provided or invalid)
|
||||
2. Validate the configuration is either "debug" or "release" (case-insensitive)
|
||||
3. Run xcodebuild with the specified configuration:
|
||||
```bash
|
||||
cd /Users/ashikkizhakkepallathu/Documents/claude/jellypig/jellypig
|
||||
|
||||
# For debug:
|
||||
xcodebuild -project jellypig.xcodeproj \
|
||||
-scheme "jellypig tvOS" \
|
||||
-sdk appletvsimulator \
|
||||
-configuration Debug \
|
||||
-derivedDataPath ./DerivedData \
|
||||
clean build \
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
|
||||
# For release:
|
||||
xcodebuild -project jellypig.xcodeproj \
|
||||
-scheme "jellypig tvOS" \
|
||||
-sdk appletvsimulator \
|
||||
-configuration Release \
|
||||
-derivedDataPath ./DerivedData \
|
||||
clean build \
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
```
|
||||
4. Report build status (success or failure)
|
||||
5. Display the output path of the built app
|
||||
|
||||
Expected output location:
|
||||
- Debug: `./DerivedData/Build/Products/Debug-appletvsimulator/jellypig tvOS.app`
|
||||
- Release: `./DerivedData/Build/Products/Release-appletvsimulator/jellypig tvOS.app`
|
|
@ -8,9 +8,10 @@ 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 (/sim, etc.)
|
||||
- Available custom slash commands (/build, /sim, etc.)
|
||||
- Recent features implemented
|
||||
- Key configuration details
|
||||
- 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,14 +1,41 @@
|
|||
---
|
||||
description: Build jellypig tvOS and launch in Apple TV simulator
|
||||
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. Boot the Apple TV simulator (16A71179-729D-4F1B-8698-8371F137025B)
|
||||
2. Open Simulator.app
|
||||
3. Build the project for tvOS Simulator
|
||||
4. Install the built app on the simulator
|
||||
5. Launch the app with bundle identifier org.ashik.jellypig
|
||||
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
|
||||
```
|
||||
|
||||
Use xcodebuild to build, xcrun simctl to manage the simulator, and report success when the app is running.
|
||||
2. Boot the Apple TV simulator:
|
||||
```bash
|
||||
xcrun simctl boot 16A71179-729D-4F1B-8698-8371F137025B 2>/dev/null || true
|
||||
```
|
||||
|
||||
3. Open Simulator.app:
|
||||
```bash
|
||||
open -a Simulator
|
||||
```
|
||||
|
||||
4. Install the built app on the simulator:
|
||||
```bash
|
||||
xcrun simctl install 16A71179-729D-4F1B-8698-8371F137025B \
|
||||
"./DerivedData/Build/Products/Debug-appletvsimulator/jellypig tvOS.app"
|
||||
```
|
||||
|
||||
5. Launch the app:
|
||||
```bash
|
||||
xcrun simctl launch 16A71179-729D-4F1B-8698-8371F137025B org.ashik.jellypig
|
||||
```
|
||||
|
||||
Report build and launch status. If any step fails, provide clear error message.
|
||||
|
|
|
@ -0,0 +1,364 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// A custom layout that arranges views in a flow pattern, automatically wrapping items to new rows
|
||||
struct FlowLayout: Layout {
|
||||
|
||||
// MARK: - Fill Direction
|
||||
|
||||
enum Direction {
|
||||
case up
|
||||
case down
|
||||
}
|
||||
|
||||
// MARK: - Cache Structure
|
||||
|
||||
struct CacheData {
|
||||
let subviewSizes: [CGSize]
|
||||
let rows: [[Int]]
|
||||
let totalSize: CGSize
|
||||
let lastWidth: CGFloat?
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
/// The alignment of content within each row (leading, center, or trailing)
|
||||
private let alignment: HorizontalAlignment
|
||||
/// Controls whether items fill from the top row down or bottom row up when wrapping
|
||||
private let direction: Direction
|
||||
/// The horizontal spacing between items within the same row
|
||||
private let spacing: CGFloat
|
||||
/// The vertical spacing between the top and bottom rows when content wraps
|
||||
private let lineSpacing: CGFloat
|
||||
/// The minimum number of items that must be in the smaller row when wrapping occurs
|
||||
private let minRowLength: Int
|
||||
|
||||
init(
|
||||
alignment: HorizontalAlignment = .center,
|
||||
direction: Direction = .up,
|
||||
spacing: CGFloat = 8,
|
||||
lineSpacing: CGFloat = 8,
|
||||
minRowLength: Int = 2
|
||||
) {
|
||||
self.alignment = alignment
|
||||
self.direction = direction
|
||||
self.spacing = spacing
|
||||
self.lineSpacing = lineSpacing
|
||||
self.minRowLength = minRowLength
|
||||
}
|
||||
|
||||
// MARK: - Make Cache
|
||||
|
||||
func makeCache(subviews: Subviews) -> CacheData {
|
||||
CacheData(
|
||||
subviewSizes: [],
|
||||
rows: [],
|
||||
totalSize: .zero,
|
||||
lastWidth: nil
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Update Cache
|
||||
|
||||
func updateCache(_ cache: inout CacheData, subviews: Subviews) {
|
||||
cache = CacheData(
|
||||
subviewSizes: [],
|
||||
rows: [],
|
||||
totalSize: .zero,
|
||||
lastWidth: nil
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Calculate Layout
|
||||
|
||||
private func calculateLayout(
|
||||
subviews: Subviews,
|
||||
width: CGFloat
|
||||
) -> (sizes: [CGSize], rows: [[Int]], totalSize: CGSize) {
|
||||
let sizes = subviews.map { subview in
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
return CGSize(width: ceil(size.width), height: ceil(size.height))
|
||||
}
|
||||
|
||||
let rows = computeRows(sizes: sizes, maxWidth: width)
|
||||
let totalSize = computeTotalSize(rows: rows, sizes: sizes)
|
||||
|
||||
return (sizes, rows, totalSize)
|
||||
}
|
||||
|
||||
// MARK: - Size That Fits
|
||||
|
||||
/// Calculates the minimum size needed to display all subviews according to the flow layout rules
|
||||
func sizeThatFits(
|
||||
proposal: ProposedViewSize,
|
||||
subviews: Subviews,
|
||||
cache: inout CacheData
|
||||
) -> CGSize {
|
||||
let availableWidth = proposal.width ?? .infinity
|
||||
let effectiveWidth = availableWidth.isFinite ? availableWidth : 1000
|
||||
|
||||
if cache.lastWidth != effectiveWidth || cache.subviewSizes.isEmpty {
|
||||
let (sizes, rows, totalSize) = calculateLayout(
|
||||
subviews: subviews,
|
||||
width: effectiveWidth
|
||||
)
|
||||
|
||||
cache = CacheData(
|
||||
subviewSizes: sizes,
|
||||
rows: rows,
|
||||
totalSize: totalSize,
|
||||
lastWidth: effectiveWidth
|
||||
)
|
||||
}
|
||||
|
||||
// Return the calculated height but respect the proposed width
|
||||
return CGSize(
|
||||
width: min(cache.totalSize.width, proposal.width ?? cache.totalSize.width),
|
||||
height: cache.totalSize.height
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Place Subviews
|
||||
|
||||
/// Positions each subview within the given bounds according to the flow layout rules
|
||||
func placeSubviews(
|
||||
in bounds: CGRect,
|
||||
proposal: ProposedViewSize,
|
||||
subviews: Subviews,
|
||||
cache: inout CacheData
|
||||
) {
|
||||
let availableWidth = bounds.width
|
||||
|
||||
if cache.lastWidth != availableWidth || cache.subviewSizes.isEmpty {
|
||||
let (sizes, rows, totalSize) = calculateLayout(
|
||||
subviews: subviews,
|
||||
width: availableWidth
|
||||
)
|
||||
|
||||
cache = CacheData(
|
||||
subviewSizes: sizes,
|
||||
rows: rows,
|
||||
totalSize: totalSize,
|
||||
lastWidth: availableWidth
|
||||
)
|
||||
}
|
||||
|
||||
let sizes = cache.subviewSizes
|
||||
let rows = cache.rows
|
||||
|
||||
var yOffset: CGFloat = bounds.minY
|
||||
|
||||
for row in rows {
|
||||
let rowHeight = row.map { sizes[$0].height }.max() ?? 0
|
||||
let rowWidth = computeRowWidth(indices: row, sizes: sizes)
|
||||
let xOffset = computeXOffset(rowWidth: rowWidth, bounds: bounds)
|
||||
|
||||
var x = xOffset
|
||||
for index in row {
|
||||
let size = sizes[index]
|
||||
let y = yOffset + (rowHeight - size.height) / 2
|
||||
|
||||
subviews[index].place(
|
||||
at: CGPoint(x: x, y: y),
|
||||
anchor: .topLeading,
|
||||
proposal: ProposedViewSize(size)
|
||||
)
|
||||
|
||||
x += size.width + spacing
|
||||
}
|
||||
|
||||
yOffset += rowHeight + lineSpacing
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Compute Rows
|
||||
|
||||
/// Determines how to distribute items across rows based on the available width
|
||||
private func computeRows(
|
||||
sizes: [CGSize],
|
||||
maxWidth: CGFloat
|
||||
) -> [[Int]] {
|
||||
guard sizes.count > 1 else {
|
||||
return sizes.isEmpty ? [] : [[0]]
|
||||
}
|
||||
|
||||
// First create rows by fitting items naturally
|
||||
let rows = createInitialRows(sizes: sizes, maxWidth: maxWidth)
|
||||
|
||||
// Then optimize distribution based on flow direction
|
||||
return optimizeRowDistribution(rows: rows, sizes: sizes, maxWidth: maxWidth)
|
||||
}
|
||||
|
||||
/// Create initial rows by fitting items sequentially
|
||||
private func createInitialRows(
|
||||
sizes: [CGSize],
|
||||
maxWidth: CGFloat
|
||||
) -> [[Int]] {
|
||||
var rows: [[Int]] = []
|
||||
var currentRow: [Int] = []
|
||||
var currentWidth: CGFloat = 0
|
||||
|
||||
for (index, size) in sizes.enumerated() {
|
||||
if currentRow.isEmpty {
|
||||
currentRow.append(index)
|
||||
currentWidth = size.width
|
||||
} else {
|
||||
let widthWithItem = currentWidth + spacing + size.width
|
||||
|
||||
if widthWithItem <= maxWidth {
|
||||
currentRow.append(index)
|
||||
currentWidth = widthWithItem
|
||||
} else {
|
||||
rows.append(currentRow)
|
||||
currentRow = [index]
|
||||
currentWidth = size.width
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !currentRow.isEmpty {
|
||||
rows.append(currentRow)
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
/// Optimize row distribution based on flow direction
|
||||
private func optimizeRowDistribution(
|
||||
rows: [[Int]],
|
||||
sizes: [CGSize],
|
||||
maxWidth: CGFloat
|
||||
) -> [[Int]] {
|
||||
guard rows.count > 1 else { return rows }
|
||||
|
||||
var optimizedRows = rows
|
||||
|
||||
switch direction {
|
||||
case .up:
|
||||
// Move items from earlier rows to later rows to create upward flow
|
||||
optimizedRows = balanceRowsForUpwardFlow(rows: optimizedRows, sizes: sizes, maxWidth: maxWidth)
|
||||
case .down:
|
||||
// Move items from later rows to earlier rows to create downward flow
|
||||
optimizedRows = balanceRowsForDownwardFlow(rows: optimizedRows, sizes: sizes, maxWidth: maxWidth)
|
||||
}
|
||||
|
||||
return optimizedRows
|
||||
}
|
||||
|
||||
/// Balance rows for upward flow - fill bottom rows more than top rows
|
||||
private func balanceRowsForUpwardFlow(
|
||||
rows: [[Int]],
|
||||
sizes: [CGSize],
|
||||
maxWidth: CGFloat
|
||||
) -> [[Int]] {
|
||||
var optimizedRows = rows
|
||||
|
||||
for i in 0 ..< optimizedRows.count - 1 {
|
||||
while optimizedRows[i].count > minRowLength {
|
||||
let lastItem = optimizedRows[i].last!
|
||||
|
||||
var testRow = optimizedRows[i + 1]
|
||||
testRow.append(lastItem)
|
||||
let newWidth = computeRowWidth(indices: testRow, sizes: sizes)
|
||||
|
||||
if newWidth <= maxWidth {
|
||||
optimizedRows[i].removeLast()
|
||||
optimizedRows[i + 1].append(lastItem)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return optimizedRows
|
||||
}
|
||||
|
||||
/// Balance rows for downward flow - fill top rows more than bottom rows
|
||||
private func balanceRowsForDownwardFlow(
|
||||
rows: [[Int]],
|
||||
sizes: [CGSize],
|
||||
maxWidth: CGFloat
|
||||
) -> [[Int]] {
|
||||
var optimizedRows = rows
|
||||
|
||||
for i in (0 ..< optimizedRows.count - 1).reversed() {
|
||||
while optimizedRows[i + 1].count > minRowLength {
|
||||
let firstItem = optimizedRows[i + 1].first!
|
||||
|
||||
var testRow = optimizedRows[i]
|
||||
testRow.append(firstItem)
|
||||
let newWidth = computeRowWidth(indices: testRow, sizes: sizes)
|
||||
|
||||
if newWidth <= maxWidth {
|
||||
optimizedRows[i + 1].removeFirst()
|
||||
optimizedRows[i].append(firstItem)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return optimizedRows
|
||||
}
|
||||
|
||||
// MARK: - Compute Row Width
|
||||
|
||||
/// Calculates the total width needed for a row of items including spacing
|
||||
private func computeRowWidth(
|
||||
indices: [Int],
|
||||
sizes: [CGSize]
|
||||
) -> CGFloat {
|
||||
guard indices.isNotEmpty else { return 0 }
|
||||
|
||||
let itemsWidth = indices.reduce(0) { $0 + sizes[$1].width }
|
||||
let spacingWidth = spacing * CGFloat(indices.count - 1)
|
||||
|
||||
return itemsWidth + spacingWidth
|
||||
}
|
||||
|
||||
// MARK: - Compute X Offset
|
||||
|
||||
/// Calculates the starting X position for a row based on the alignment setting
|
||||
private func computeXOffset(
|
||||
rowWidth: CGFloat,
|
||||
bounds: CGRect
|
||||
) -> CGFloat {
|
||||
switch alignment {
|
||||
case .trailing:
|
||||
return bounds.maxX - rowWidth
|
||||
case .center:
|
||||
return bounds.minX + (bounds.width - rowWidth) / 2
|
||||
default:
|
||||
return bounds.minX
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Compute Total Size
|
||||
|
||||
/// Calculates the total size needed to display all rows with proper spacing
|
||||
private func computeTotalSize(
|
||||
rows: [[Int]],
|
||||
sizes: [CGSize]
|
||||
) -> CGSize {
|
||||
guard rows.isNotEmpty else { return .zero }
|
||||
|
||||
let rowHeights = rows.map { row in
|
||||
row.map { sizes[$0].height }.max() ?? 0
|
||||
}
|
||||
|
||||
let totalHeight = rowHeights.reduce(0, +) + lineSpacing * CGFloat(rows.count - 1)
|
||||
|
||||
let maxWidth = rows.map { row in
|
||||
computeRowWidth(indices: row, sizes: sizes)
|
||||
}.max() ?? 0
|
||||
|
||||
return CGSize(width: maxWidth, height: totalHeight)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,131 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
struct CountryPicker: View {
|
||||
|
||||
// MARK: - State Objects
|
||||
|
||||
@StateObject
|
||||
private var viewModel: CountriesViewModel
|
||||
|
||||
// MARK: - Input Properties
|
||||
|
||||
private var selectionBinding: Binding<CountryInfo?>
|
||||
private let title: String
|
||||
|
||||
@State
|
||||
private var selection: CountryInfo?
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(tvOS)
|
||||
ListRowMenu(title, subtitle: $selection.wrappedValue?.displayTitle) {
|
||||
Picker(title, selection: $selection) {
|
||||
Text(CountryInfo.none.displayTitle)
|
||||
.tag(CountryInfo.none as CountryInfo?)
|
||||
|
||||
ForEach(viewModel.value, id: \.self) { country in
|
||||
Text(country.displayTitle)
|
||||
.tag(country as CountryInfo?)
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: iOS 17+ move this to the Group
|
||||
.onChange(of: viewModel.value) {
|
||||
updateSelection()
|
||||
}
|
||||
.onChange(of: selection) { _, newValue in
|
||||
selectionBinding.wrappedValue = newValue
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.listRowInsets(.zero)
|
||||
#else
|
||||
Picker(title, selection: $selection) {
|
||||
|
||||
Text(CountryInfo.none.displayTitle)
|
||||
.tag(CountryInfo.none as CountryInfo?)
|
||||
|
||||
ForEach(viewModel.value, id: \.self) { country in
|
||||
Text(country.displayTitle)
|
||||
.tag(country as CountryInfo?)
|
||||
}
|
||||
}
|
||||
// TODO: iOS 17+ delete this and use the tvOS onChange at the Group level
|
||||
.onChange(of: viewModel.value) { _ in
|
||||
updateSelection()
|
||||
}
|
||||
.onChange(of: selection) { newValue in
|
||||
selectionBinding.wrappedValue = newValue
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.onFirstAppear {
|
||||
viewModel.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSelection() {
|
||||
let newValue = viewModel.value.first { value in
|
||||
if let selectedTwo = selection?.twoLetterISORegionName,
|
||||
let candidateTwo = value.twoLetterISORegionName,
|
||||
selectedTwo == candidateTwo
|
||||
{
|
||||
return true
|
||||
}
|
||||
if let selectedThree = selection?.threeLetterISORegionName,
|
||||
let candidateThree = value.threeLetterISORegionName,
|
||||
selectedThree == candidateThree
|
||||
{
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
selection = newValue ?? CountryInfo.none
|
||||
}
|
||||
}
|
||||
|
||||
extension CountryPicker {
|
||||
|
||||
init(_ title: String, twoLetterISORegion: Binding<String?>) {
|
||||
self.title = title
|
||||
self._selection = State(
|
||||
initialValue: twoLetterISORegion.wrappedValue.flatMap { code in
|
||||
CountryInfo(
|
||||
name: code,
|
||||
twoLetterISORegionName: code
|
||||
)
|
||||
} ?? CountryInfo.none
|
||||
)
|
||||
self.selectionBinding = Binding(
|
||||
get: {
|
||||
guard let code = twoLetterISORegion.wrappedValue else {
|
||||
return CountryInfo.none
|
||||
}
|
||||
return CountryInfo(
|
||||
name: code,
|
||||
twoLetterISORegionName: code
|
||||
)
|
||||
},
|
||||
set: { newCountry in
|
||||
twoLetterISORegion.wrappedValue = newCountry?.twoLetterISORegionName
|
||||
}
|
||||
)
|
||||
|
||||
self._viewModel = StateObject(
|
||||
wrappedValue: CountriesViewModel(
|
||||
initialValue: [.none]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,153 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
struct CulturePicker: View {
|
||||
|
||||
// MARK: - State Objects
|
||||
|
||||
@StateObject
|
||||
private var viewModel: CulturesViewModel
|
||||
|
||||
// MARK: - Input Properties
|
||||
|
||||
private var selectionBinding: Binding<CultureDto?>
|
||||
private let title: String
|
||||
|
||||
@State
|
||||
private var selection: CultureDto?
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(tvOS)
|
||||
ListRowMenu(title, subtitle: $selection.wrappedValue?.displayTitle) {
|
||||
Picker(title, selection: $selection) {
|
||||
Text(CultureDto.none.displayTitle)
|
||||
.tag(CultureDto.none as CultureDto?)
|
||||
|
||||
ForEach(viewModel.value, id: \.self) { value in
|
||||
Text(value.displayTitle)
|
||||
.tag(value as CultureDto?)
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: iOS 17+ move this to the Group
|
||||
.onChange(of: viewModel.value) {
|
||||
updateSelection()
|
||||
}
|
||||
.onChange(of: selection) { _, newValue in
|
||||
selectionBinding.wrappedValue = newValue
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.listRowInsets(.zero)
|
||||
#else
|
||||
Picker(title, selection: $selection) {
|
||||
|
||||
Text(CultureDto.none.displayTitle)
|
||||
.tag(CultureDto.none as CultureDto?)
|
||||
|
||||
ForEach(viewModel.value, id: \.self) { value in
|
||||
Text(value.displayTitle)
|
||||
.tag(value as CultureDto?)
|
||||
}
|
||||
}
|
||||
// TODO: iOS 17+ delete this and use the tvOS onChange at the Group level
|
||||
.onChange(of: viewModel.value) { _ in
|
||||
updateSelection()
|
||||
}
|
||||
.onChange(of: selection) { newValue in
|
||||
selectionBinding.wrappedValue = newValue
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.onFirstAppear {
|
||||
viewModel.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSelection() {
|
||||
let newValue = viewModel.value.first { value in
|
||||
if let selectedTwo = selection?.twoLetterISOLanguageName,
|
||||
let candidateTwo = value.twoLetterISOLanguageName,
|
||||
selectedTwo == candidateTwo
|
||||
{
|
||||
return true
|
||||
}
|
||||
if let selectedThree = selection?.threeLetterISOLanguageName,
|
||||
let candidateThree = value.threeLetterISOLanguageName,
|
||||
selectedThree == candidateThree
|
||||
{
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
selection = newValue ?? CultureDto.none
|
||||
}
|
||||
}
|
||||
|
||||
extension CulturePicker {
|
||||
|
||||
init(_ title: String, twoLetterISOLanguageName: Binding<String?>) {
|
||||
self.title = title
|
||||
self._selection = State(
|
||||
initialValue: twoLetterISOLanguageName.wrappedValue.flatMap {
|
||||
CultureDto(twoLetterISOLanguageName: $0)
|
||||
} ?? CultureDto.none
|
||||
)
|
||||
|
||||
self.selectionBinding = Binding<CultureDto?>(
|
||||
get: {
|
||||
guard let code = twoLetterISOLanguageName.wrappedValue else {
|
||||
return CultureDto.none
|
||||
}
|
||||
return CultureDto(twoLetterISOLanguageName: code)
|
||||
},
|
||||
set: { newCountry in
|
||||
twoLetterISOLanguageName.wrappedValue = newCountry?.twoLetterISOLanguageName
|
||||
}
|
||||
)
|
||||
|
||||
self._viewModel = StateObject(
|
||||
wrappedValue: CulturesViewModel(
|
||||
initialValue: [.none]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
init(_ title: String, threeLetterISOLanguageName: Binding<String?>) {
|
||||
self.title = title
|
||||
self._selection = State(
|
||||
initialValue: threeLetterISOLanguageName.wrappedValue.flatMap {
|
||||
CultureDto(threeLetterISOLanguageName: $0)
|
||||
} ?? CultureDto.none
|
||||
)
|
||||
|
||||
self.selectionBinding = Binding<CultureDto?>(
|
||||
get: {
|
||||
guard let code = threeLetterISOLanguageName.wrappedValue else {
|
||||
return CultureDto.none
|
||||
}
|
||||
return CultureDto(threeLetterISOLanguageName: code)
|
||||
},
|
||||
set: { newCountry in
|
||||
threeLetterISOLanguageName.wrappedValue = newCountry?.threeLetterISOLanguageName
|
||||
}
|
||||
)
|
||||
|
||||
self._viewModel = StateObject(
|
||||
wrappedValue: CulturesViewModel(
|
||||
initialValue: [.none]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,120 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
struct ParentalRatingPicker: View {
|
||||
|
||||
// MARK: - State Objects
|
||||
|
||||
@StateObject
|
||||
private var viewModel: ParentalRatingsViewModel
|
||||
|
||||
// MARK: - Input Properties
|
||||
|
||||
private var selectionBinding: Binding<ParentalRating?>
|
||||
private let title: String
|
||||
|
||||
@State
|
||||
private var selection: ParentalRating?
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(tvOS)
|
||||
ListRowMenu(title, subtitle: $selection.wrappedValue?.displayTitle) {
|
||||
Picker(title, selection: $selection) {
|
||||
Text(ParentalRating.none.displayTitle)
|
||||
.tag(ParentalRating.none as ParentalRating?)
|
||||
|
||||
ForEach(viewModel.value, id: \.self) { value in
|
||||
Text(value.displayTitle)
|
||||
.tag(value as ParentalRating?)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.value) {
|
||||
updateSelection()
|
||||
}
|
||||
.onChange(of: selection) { _, newValue in
|
||||
selectionBinding.wrappedValue = newValue
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.listRowInsets(.zero)
|
||||
#else
|
||||
Picker(title, selection: $selection) {
|
||||
|
||||
Text(ParentalRating.none.displayTitle)
|
||||
.tag(ParentalRating.none as ParentalRating?)
|
||||
|
||||
ForEach(viewModel.value, id: \.self) { value in
|
||||
Text(value.displayTitle)
|
||||
.tag(value as ParentalRating?)
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.value) { _ in
|
||||
updateSelection()
|
||||
}
|
||||
.onChange(of: selection) { newValue in
|
||||
selectionBinding.wrappedValue = newValue
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.onFirstAppear {
|
||||
viewModel.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Update Selection
|
||||
|
||||
private func updateSelection() {
|
||||
let newValue = viewModel.value.first { value in
|
||||
if let selectedName = selection?.name,
|
||||
let candidateName = value.name,
|
||||
selectedName == candidateName
|
||||
{
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
selection = newValue ?? ParentalRating.none
|
||||
}
|
||||
}
|
||||
|
||||
extension ParentalRatingPicker {
|
||||
|
||||
init(_ title: String, name: Binding<String?>) {
|
||||
self.title = title
|
||||
self._selection = State(
|
||||
initialValue: name.wrappedValue.flatMap {
|
||||
ParentalRating(name: $0)
|
||||
} ?? ParentalRating.none
|
||||
)
|
||||
|
||||
self.selectionBinding = Binding<ParentalRating?>(
|
||||
get: {
|
||||
guard let ratingName = name.wrappedValue else {
|
||||
return ParentalRating.none
|
||||
}
|
||||
return ParentalRating(name: ratingName)
|
||||
},
|
||||
set: { newRating in
|
||||
name.wrappedValue = newRating?.name
|
||||
}
|
||||
)
|
||||
|
||||
self._viewModel = StateObject(
|
||||
wrappedValue: ParentalRatingsViewModel(
|
||||
initialValue: [.none]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,91 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// A `VStack` that displays subviews with a marker on the top leading edge.
|
||||
///
|
||||
/// In a marker view, ensure that views that are only used for layout are
|
||||
/// tagged with `hidden` to avoid them being read by accessibility features.
|
||||
struct MarkedList<Content: View, Marker: View>: View {
|
||||
|
||||
private let content: Content
|
||||
private let marker: (Int) -> Marker
|
||||
private let spacing: CGFloat
|
||||
|
||||
init(
|
||||
spacing: CGFloat,
|
||||
@ViewBuilder marker: @escaping (Int) -> Marker,
|
||||
@ViewBuilder content: @escaping () -> Content
|
||||
) {
|
||||
self.marker = marker
|
||||
self.content = content()
|
||||
self.spacing = spacing
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
_VariadicView.Tree(
|
||||
MarkedListLayout(
|
||||
spacing: spacing,
|
||||
marker: marker
|
||||
)
|
||||
) {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MarkedList {
|
||||
|
||||
struct MarkedListLayout: _VariadicView_UnaryViewRoot {
|
||||
|
||||
let spacing: CGFloat
|
||||
let marker: (Int) -> Marker
|
||||
|
||||
@ViewBuilder
|
||||
func body(children: _VariadicView.Children) -> some View {
|
||||
VStack(alignment: .leading, spacing: spacing) {
|
||||
ForEach(Array(zip(children.indices, children)), id: \.0) { child in
|
||||
MarkedListEntry(
|
||||
marker: marker(child.0),
|
||||
content: child.1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MarkedListEntry<EntryContent: View>: View {
|
||||
|
||||
@State
|
||||
private var markerSize: CGSize = .zero
|
||||
@State
|
||||
private var childSize: CGSize = .zero
|
||||
|
||||
let marker: Marker
|
||||
let content: EntryContent
|
||||
|
||||
private var _bullet: some View {
|
||||
marker
|
||||
.trackingSize($markerSize)
|
||||
}
|
||||
|
||||
// TODO: this can cause clipping issues with text since
|
||||
// with .offset, find fix
|
||||
var body: some View {
|
||||
ZStack {
|
||||
content
|
||||
.trackingSize($childSize)
|
||||
.overlay(alignment: .topLeading) {
|
||||
_bullet
|
||||
.offset(x: -markerSize.width)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,111 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import AVKit
|
||||
import Factory
|
||||
import JellyfinAPI
|
||||
import Logging
|
||||
import SwiftUI
|
||||
|
||||
// TODO: remove
|
||||
|
||||
struct NativeVideoPlayer: View {
|
||||
|
||||
@Environment(\.presentationCoordinator)
|
||||
private var presentationCoordinator
|
||||
|
||||
@InjectedObject(\.mediaPlayerManager)
|
||||
private var manager: MediaPlayerManager
|
||||
|
||||
@LazyState
|
||||
private var proxy: AVMediaPlayerProxy
|
||||
|
||||
@Router
|
||||
private var router
|
||||
|
||||
init() {
|
||||
self._proxy = .init(wrappedValue: AVMediaPlayerProxy())
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
|
||||
Color.black
|
||||
|
||||
switch manager.state {
|
||||
case .playback:
|
||||
NativeVideoPlayerView(proxy: proxy)
|
||||
default:
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
manager.proxy = proxy
|
||||
manager.start()
|
||||
}
|
||||
.preference(key: IsStatusBarHiddenKey.self, value: true)
|
||||
.backport
|
||||
.onChange(of: presentationCoordinator.isPresented) { _, isPresented in
|
||||
Container.shared.mediaPlayerManager.reset()
|
||||
guard !isPresented else { return }
|
||||
manager.stop()
|
||||
}
|
||||
.alert(
|
||||
L10n.error,
|
||||
isPresented: .constant(manager.error != nil)
|
||||
) {
|
||||
Button(L10n.close, role: .cancel) {
|
||||
Container.shared.mediaPlayerManager.reset()
|
||||
router.dismiss()
|
||||
}
|
||||
} message: {
|
||||
// TODO: localize
|
||||
Text("Unable to load this item.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NativeVideoPlayer {
|
||||
|
||||
private struct NativeVideoPlayerView: UIViewControllerRepresentable {
|
||||
|
||||
let proxy: AVMediaPlayerProxy
|
||||
|
||||
func makeUIViewController(context: Context) -> UINativeVideoPlayerViewController {
|
||||
UINativeVideoPlayerViewController(proxy: proxy)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UINativeVideoPlayerViewController, context: Context) {}
|
||||
}
|
||||
|
||||
private class UINativeVideoPlayerViewController: AVPlayerViewController {
|
||||
|
||||
private let proxy: AVMediaPlayerProxy
|
||||
|
||||
init(proxy: AVMediaPlayerProxy) {
|
||||
self.proxy = proxy
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
player = proxy.player
|
||||
|
||||
player?.allowsExternalPlayback = true
|
||||
player?.appliesMediaSelectionCriteriaAutomatically = false
|
||||
allowsPictureInPicturePlayback = true
|
||||
|
||||
#if !os(tvOS)
|
||||
updatesNowPlayingInfoCenter = false
|
||||
#endif
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import BlurHashKit
|
||||
import SwiftUI
|
||||
|
||||
/// Retrieving images by exact pixel dimensions is a bit
|
||||
/// intense for normal usage and eases cache usage and modifications.
|
||||
private let landscapeMaxWidth: CGFloat = 300
|
||||
private let portraitMaxWidth: CGFloat = 200
|
||||
|
||||
struct PosterImage<Item: Poster>: View {
|
||||
|
||||
private let contentMode: ContentMode
|
||||
private let imageMaxWidth: CGFloat
|
||||
private let item: Item
|
||||
private let type: PosterDisplayType
|
||||
|
||||
init(
|
||||
item: Item,
|
||||
type: PosterDisplayType,
|
||||
contentMode: ContentMode = .fill,
|
||||
maxWidth: CGFloat? = nil
|
||||
) {
|
||||
self.contentMode = contentMode
|
||||
self.imageMaxWidth = maxWidth ?? (type == .landscape ? landscapeMaxWidth : portraitMaxWidth)
|
||||
self.item = item
|
||||
self.type = type
|
||||
}
|
||||
|
||||
private var imageSources: [ImageSource] {
|
||||
switch type {
|
||||
case .landscape:
|
||||
item.landscapeImageSources(maxWidth: imageMaxWidth, quality: 90)
|
||||
case .portrait:
|
||||
item.portraitImageSources(maxWidth: imageMaxWidth, quality: 90)
|
||||
case .square:
|
||||
item.squareImageSources(maxWidth: imageMaxWidth, quality: 90)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Rectangle()
|
||||
.fill(.complexSecondary)
|
||||
|
||||
AlternateLayoutView {
|
||||
Color.clear
|
||||
} content: {
|
||||
ImageView(imageSources)
|
||||
.image(item.transform)
|
||||
.placeholder { imageSource in
|
||||
if let blurHash = imageSource.blurHash {
|
||||
BlurHashView(blurHash: blurHash)
|
||||
} else if item.showTitle {
|
||||
SystemImageContentView(
|
||||
systemName: item.systemImage
|
||||
)
|
||||
} else {
|
||||
SystemImageContentView(
|
||||
title: item.displayTitle,
|
||||
systemName: item.systemImage
|
||||
)
|
||||
}
|
||||
}
|
||||
.failure {
|
||||
if item.showTitle {
|
||||
SystemImageContentView(
|
||||
systemName: item.systemImage
|
||||
)
|
||||
} else {
|
||||
SystemImageContentView(
|
||||
title: item.displayTitle,
|
||||
systemName: item.systemImage
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.posterStyle(
|
||||
type,
|
||||
contentMode: contentMode
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct TintedMaterial: UIViewRepresentable {
|
||||
|
||||
let tint: Color
|
||||
|
||||
func makeUIView(context: Context) -> UIVisualEffectView {
|
||||
UIVisualEffectView(effect: UIBlurEffect(style: .extraLight))
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIVisualEffectView, context: Context) {
|
||||
set(tint: tint, for: uiView)
|
||||
}
|
||||
|
||||
private func set(tint: Color, for view: UIVisualEffectView) {
|
||||
let overlayView = view.subviews.first { type(of: $0) == NSClassFromString("_UIVisualEffectSubview") }
|
||||
overlayView?.backgroundColor = UIColor(tint.opacity(0.75))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import PulseUI
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
|
||||
final class AppSettingsCoordinator: NavigationCoordinatable {
|
||||
|
||||
let stack = NavigationStack(initial: \AppSettingsCoordinator.start)
|
||||
|
||||
@Root
|
||||
var start = makeStart
|
||||
|
||||
#if os(iOS)
|
||||
@Route(.push)
|
||||
var about = makeAbout
|
||||
@Route(.push)
|
||||
var appIconSelector = makeAppIconSelector
|
||||
@Route(.push)
|
||||
var log = makeLog
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
@Route(.push)
|
||||
var log = makeLog
|
||||
|
||||
@Route(.fullScreen)
|
||||
var hourPicker = makeHourPicker
|
||||
#endif
|
||||
|
||||
init() {}
|
||||
|
||||
#if os(iOS)
|
||||
@ViewBuilder
|
||||
func makeAbout(viewModel: SettingsViewModel) -> some View {
|
||||
AboutAppView(viewModel: viewModel)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeAppIconSelector(viewModel: SettingsViewModel) -> some View {
|
||||
AppIconSelectorView(viewModel: viewModel)
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder
|
||||
func makeLog() -> some View {
|
||||
ConsoleView()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeStart() -> some View {
|
||||
AppSettingsView()
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
@ViewBuilder
|
||||
func makeHourPicker() -> some View {
|
||||
ZStack {
|
||||
BlurView()
|
||||
.ignoresSafeArea()
|
||||
|
||||
HourMinutePicker()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import PulseUI
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
|
||||
final class AppSettingsCoordinator: NavigationCoordinatable {
|
||||
|
||||
let stack = NavigationStack(initial: \AppSettingsCoordinator.start)
|
||||
|
||||
@Root
|
||||
var start = makeStart
|
||||
|
||||
#if os(iOS)
|
||||
@Route(.push)
|
||||
var about = makeAbout
|
||||
@Route(.push)
|
||||
var appIconSelector = makeAppIconSelector
|
||||
@Route(.push)
|
||||
var log = makeLog
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
@Route(.push)
|
||||
var log = makeLog
|
||||
|
||||
@Route(.fullScreen)
|
||||
var hourPicker = makeHourPicker
|
||||
#endif
|
||||
|
||||
init() {}
|
||||
|
||||
#if os(iOS)
|
||||
@ViewBuilder
|
||||
func makeAbout(viewModel: SettingsViewModel) -> some View {
|
||||
AboutAppView(viewModel: viewModel)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeAppIconSelector(viewModel: SettingsViewModel) -> some View {
|
||||
AppIconSelectorView(viewModel: viewModel)
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder
|
||||
func makeLog() -> some View {
|
||||
ConsoleView()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeStart() -> some View {
|
||||
AppSettingsView()
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
@ViewBuilder
|
||||
func makeHourPicker() -> some View {
|
||||
ZStack {
|
||||
BlurView()
|
||||
.ignoresSafeArea()
|
||||
|
||||
HourMinutePicker()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
|
||||
final class HomeCoordinator: NavigationCoordinatable {
|
||||
|
||||
let stack = NavigationStack(initial: \HomeCoordinator.start)
|
||||
|
||||
@Root
|
||||
var start = makeStart
|
||||
|
||||
#if os(tvOS)
|
||||
@Route(.push)
|
||||
var item = makeItem
|
||||
@Route(.push)
|
||||
var library = makeLibrary
|
||||
#else
|
||||
@Route(.push)
|
||||
var item = makeItem
|
||||
@Route(.push)
|
||||
var library = makeLibrary
|
||||
#endif
|
||||
|
||||
func makeItem(item: BaseItemDto) -> ItemCoordinator {
|
||||
ItemCoordinator(item: item)
|
||||
}
|
||||
|
||||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
|
||||
LibraryCoordinator(viewModel: viewModel)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeStart() -> some View {
|
||||
HomeView()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
|
||||
final class HomeCoordinator: NavigationCoordinatable {
|
||||
|
||||
let stack = NavigationStack(initial: \HomeCoordinator.start)
|
||||
|
||||
@Root
|
||||
var start = makeStart
|
||||
|
||||
#if os(tvOS)
|
||||
@Route(.push)
|
||||
var item = makeItem
|
||||
@Route(.push)
|
||||
var library = makeLibrary
|
||||
#else
|
||||
@Route(.push)
|
||||
var item = makeItem
|
||||
@Route(.push)
|
||||
var library = makeLibrary
|
||||
#endif
|
||||
|
||||
func makeItem(item: BaseItemDto) -> ItemCoordinator {
|
||||
ItemCoordinator(item: item)
|
||||
}
|
||||
|
||||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
|
||||
LibraryCoordinator(viewModel: viewModel)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeStart() -> some View {
|
||||
HomeView()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
|
||||
final class LibraryCoordinator<Element: Poster>: NavigationCoordinatable {
|
||||
|
||||
let stack = NavigationStack(initial: \LibraryCoordinator.start)
|
||||
|
||||
@Root
|
||||
var start = makeStart
|
||||
|
||||
#if os(tvOS)
|
||||
@Route(.push)
|
||||
var item = makeItem
|
||||
@Route(.push)
|
||||
var library = makeLibrary
|
||||
#else
|
||||
@Route(.push)
|
||||
var item = makeItem
|
||||
@Route(.push)
|
||||
var library = makeLibrary
|
||||
@Route(.modal)
|
||||
var filter = makeFilter
|
||||
#endif
|
||||
|
||||
private let viewModel: PagingLibraryViewModel<Element>
|
||||
|
||||
init(viewModel: PagingLibraryViewModel<Element>) {
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeStart() -> some View {
|
||||
PagingLibraryView(viewModel: viewModel)
|
||||
}
|
||||
|
||||
func makeItem(item: BaseItemDto) -> ItemCoordinator {
|
||||
ItemCoordinator(item: item)
|
||||
}
|
||||
|
||||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
|
||||
LibraryCoordinator<BaseItemDto>(viewModel: viewModel)
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator<FilterCoordinator> {
|
||||
NavigationViewCoordinator(FilterCoordinator(parameters: parameters))
|
||||
}
|
||||
#endif
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
|
||||
final class LibraryCoordinator<Element: Poster>: NavigationCoordinatable {
|
||||
|
||||
let stack = NavigationStack(initial: \LibraryCoordinator.start)
|
||||
|
||||
@Root
|
||||
var start = makeStart
|
||||
|
||||
#if os(tvOS)
|
||||
@Route(.push)
|
||||
var item = makeItem
|
||||
@Route(.push)
|
||||
var library = makeLibrary
|
||||
#else
|
||||
@Route(.push)
|
||||
var item = makeItem
|
||||
@Route(.push)
|
||||
var library = makeLibrary
|
||||
@Route(.modal)
|
||||
var filter = makeFilter
|
||||
#endif
|
||||
|
||||
private let viewModel: PagingLibraryViewModel<Element>
|
||||
|
||||
init(viewModel: PagingLibraryViewModel<Element>) {
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeStart() -> some View {
|
||||
PagingLibraryView(viewModel: viewModel)
|
||||
}
|
||||
|
||||
func makeItem(item: BaseItemDto) -> ItemCoordinator {
|
||||
ItemCoordinator(item: item)
|
||||
}
|
||||
|
||||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
|
||||
LibraryCoordinator<BaseItemDto>(viewModel: viewModel)
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator<FilterCoordinator> {
|
||||
NavigationViewCoordinator(FilterCoordinator(parameters: parameters))
|
||||
}
|
||||
#endif
|
||||
}
|
|
@ -69,6 +69,8 @@ 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 {
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
|
||||
final class MediaCoordinator: NavigationCoordinatable {
|
||||
|
||||
let stack = NavigationStack(initial: \MediaCoordinator.start)
|
||||
|
||||
@Root
|
||||
var start = makeStart
|
||||
#if os(tvOS)
|
||||
@Route(.fullScreen)
|
||||
var library = makeLibrary
|
||||
@Route(.fullScreen)
|
||||
var liveTV = makeLiveTV
|
||||
#else
|
||||
@Route(.push)
|
||||
var library = makeLibrary
|
||||
@Route(.push)
|
||||
var liveTV = makeLiveTV
|
||||
@Route(.push)
|
||||
var downloads = makeDownloads
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> NavigationViewCoordinator<LibraryCoordinator<BaseItemDto>> {
|
||||
NavigationViewCoordinator(LibraryCoordinator(viewModel: viewModel))
|
||||
}
|
||||
#else
|
||||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
|
||||
LibraryCoordinator(viewModel: viewModel)
|
||||
}
|
||||
|
||||
func makeDownloads() -> DownloadListCoordinator {
|
||||
DownloadListCoordinator()
|
||||
}
|
||||
#endif
|
||||
|
||||
func makeLiveTV() -> LiveTVCoordinator {
|
||||
LiveTVCoordinator()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeStart() -> some View {
|
||||
MediaView()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
|
||||
final class MediaCoordinator: NavigationCoordinatable {
|
||||
|
||||
let stack = NavigationStack(initial: \MediaCoordinator.start)
|
||||
|
||||
@Root
|
||||
var start = makeStart
|
||||
#if os(tvOS)
|
||||
@Route(.fullScreen)
|
||||
var library = makeLibrary
|
||||
@Route(.fullScreen)
|
||||
var liveTV = makeLiveTV
|
||||
#else
|
||||
@Route(.push)
|
||||
var library = makeLibrary
|
||||
@Route(.push)
|
||||
var liveTV = makeLiveTV
|
||||
@Route(.push)
|
||||
var downloads = makeDownloads
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> NavigationViewCoordinator<LibraryCoordinator<BaseItemDto>> {
|
||||
NavigationViewCoordinator(LibraryCoordinator(viewModel: viewModel))
|
||||
}
|
||||
#else
|
||||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
|
||||
LibraryCoordinator(viewModel: viewModel)
|
||||
}
|
||||
|
||||
func makeDownloads() -> DownloadListCoordinator {
|
||||
DownloadListCoordinator()
|
||||
}
|
||||
#endif
|
||||
|
||||
func makeLiveTV() -> LiveTVCoordinator {
|
||||
LiveTVCoordinator()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeStart() -> some View {
|
||||
MediaView()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class NavigationCoordinator: ObservableObject {
|
||||
|
||||
@Published
|
||||
var path: [NavigationRoute] = []
|
||||
|
||||
@Published
|
||||
var presentedSheet: NavigationRoute?
|
||||
@Published
|
||||
var presentedFullScreen: NavigationRoute?
|
||||
|
||||
func push(
|
||||
_ route: NavigationRoute
|
||||
) {
|
||||
let style = route.transitionStyle
|
||||
|
||||
#if os(tvOS)
|
||||
switch style {
|
||||
case .push, .sheet:
|
||||
presentedSheet = route
|
||||
case .fullscreen:
|
||||
presentedFullScreen = route
|
||||
}
|
||||
#else
|
||||
switch style {
|
||||
case .push:
|
||||
path.append(route)
|
||||
case .sheet:
|
||||
presentedSheet = route
|
||||
case .fullscreen:
|
||||
withAnimation {
|
||||
presentedFullScreen = route
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
|
@ -0,0 +1,106 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import PreferencesView
|
||||
import SwiftUI
|
||||
import Transmission
|
||||
|
||||
// TODO: have full screen zoom presentation zoom from/to center
|
||||
// - probably need to make mock view with matching ids
|
||||
// TODO: have presentation dismissal be through preference keys
|
||||
// - issue with all of the VC/view wrapping
|
||||
|
||||
extension EnvironmentValues {
|
||||
|
||||
@Entry
|
||||
var presentationControllerShouldDismiss: Binding<Bool> = .constant(true)
|
||||
}
|
||||
|
||||
struct NavigationInjectionView: View {
|
||||
|
||||
@StateObject
|
||||
private var coordinator: NavigationCoordinator
|
||||
@EnvironmentObject
|
||||
private var rootCoordinator: RootCoordinator
|
||||
|
||||
@State
|
||||
private var isPresentationInteractive: Bool = true
|
||||
|
||||
private let content: AnyView
|
||||
|
||||
init(
|
||||
coordinator: @autoclosure @escaping () -> NavigationCoordinator,
|
||||
@ViewBuilder content: @escaping () -> some View
|
||||
) {
|
||||
_coordinator = StateObject(wrappedValue: coordinator())
|
||||
self.content = AnyView(content())
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: $coordinator.path) {
|
||||
content
|
||||
.navigationDestination(for: NavigationRoute.self) { route in
|
||||
route.destination
|
||||
}
|
||||
}
|
||||
.environment(
|
||||
\.router,
|
||||
.init(
|
||||
navigationCoordinator: coordinator,
|
||||
rootCoordinator: rootCoordinator
|
||||
)
|
||||
)
|
||||
.sheet(
|
||||
item: $coordinator.presentedSheet
|
||||
) {
|
||||
coordinator.presentedSheet = nil
|
||||
} content: { route in
|
||||
let newCoordinator = NavigationCoordinator()
|
||||
|
||||
NavigationInjectionView(coordinator: newCoordinator) {
|
||||
route.destination
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.fullScreenCover(
|
||||
item: $coordinator.presentedFullScreen
|
||||
) { route in
|
||||
let newCoordinator = NavigationCoordinator()
|
||||
|
||||
NavigationInjectionView(coordinator: newCoordinator) {
|
||||
route.destination
|
||||
}
|
||||
}
|
||||
#else
|
||||
.presentation(
|
||||
$coordinator.presentedFullScreen,
|
||||
transition: .zoomIfAvailable(
|
||||
options: .init(
|
||||
dimmingVisualEffect: .systemThickMaterialDark,
|
||||
options: .init(
|
||||
isInteractive: isPresentationInteractive
|
||||
)
|
||||
),
|
||||
otherwise: .slide(.init(edge: .bottom), options: .init(isInteractive: isPresentationInteractive))
|
||||
)
|
||||
) { routeBinding, _ in
|
||||
let vc = UIPreferencesHostingController {
|
||||
NavigationInjectionView(coordinator: .init()) {
|
||||
routeBinding.wrappedValue.destination
|
||||
.environment(\.presentationControllerShouldDismiss, $isPresentationInteractive)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: presentation options for customizing background color, dimming effect, etc.
|
||||
vc.view.backgroundColor = .black
|
||||
|
||||
return vc
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
|
@ -0,0 +1,226 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
extension NavigationRoute {
|
||||
|
||||
// MARK: - Active Sessions
|
||||
|
||||
static func activeDeviceDetails(box: BindingBox<SessionInfoDto?>) -> NavigationRoute {
|
||||
NavigationRoute(id: "activeDeviceDetails") {
|
||||
ActiveSessionDetailView(box: box)
|
||||
}
|
||||
}
|
||||
|
||||
static let activeSessions = NavigationRoute(
|
||||
id: "activeSessions"
|
||||
) {
|
||||
ActiveSessionsView()
|
||||
}
|
||||
|
||||
// MARK: - User Activity
|
||||
|
||||
static let activity = NavigationRoute(
|
||||
id: "activity"
|
||||
) {
|
||||
ServerActivityView()
|
||||
}
|
||||
|
||||
static func activityDetails(viewModel: ServerActivityDetailViewModel) -> NavigationRoute {
|
||||
NavigationRoute(id: "activityDetails") {
|
||||
ServerActivityDetailsView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
static func activityFilters(viewModel: ServerActivityViewModel) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "activityFilters",
|
||||
style: .sheet
|
||||
) {
|
||||
ServerActivityFilterView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Server Tasks
|
||||
|
||||
static func addServerTaskTrigger(observer: ServerTaskObserver) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "addServerTaskTrigger",
|
||||
style: .sheet
|
||||
) {
|
||||
AddTaskTriggerView(observer: observer)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Users
|
||||
|
||||
static func addServerUser() -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "addServerUser",
|
||||
style: .sheet
|
||||
) {
|
||||
AddServerUserView()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - API Keys
|
||||
|
||||
static let apiKeys = NavigationRoute(
|
||||
id: "apiKeys"
|
||||
) {
|
||||
APIKeysView()
|
||||
}
|
||||
|
||||
// MARK: - Devices
|
||||
|
||||
static func deviceDetails(device: DeviceInfoDto, viewModel: DevicesViewModel) -> NavigationRoute {
|
||||
NavigationRoute(id: "deviceDetails") {
|
||||
DeviceDetailsView(device: device, viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
static let devices = NavigationRoute(
|
||||
id: "devices"
|
||||
) {
|
||||
DevicesView()
|
||||
}
|
||||
|
||||
// MARK: - Server Tasks
|
||||
|
||||
static func editServerTask(observer: ServerTaskObserver) -> NavigationRoute {
|
||||
NavigationRoute(id: "editServerTask") {
|
||||
EditServerTaskView(observer: observer)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Users
|
||||
|
||||
static func quickConnectAuthorize(user: UserDto) -> NavigationRoute {
|
||||
NavigationRoute(id: "quickConnectAuthorize") {
|
||||
QuickConnectAuthorizeView(user: user)
|
||||
}
|
||||
}
|
||||
|
||||
static func resetUserPasswordAdmin(userID: String) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "resetUserPasswordAdmin",
|
||||
style: .sheet
|
||||
) {
|
||||
ResetUserPasswordView(userID: userID, requiresCurrentPassword: false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Server Logs
|
||||
|
||||
static let serverLogs = NavigationRoute(
|
||||
id: "serverLogs"
|
||||
) {
|
||||
ServerLogsView()
|
||||
}
|
||||
|
||||
// MARK: - Server Tasks
|
||||
|
||||
static let tasks = NavigationRoute(
|
||||
id: "tasks"
|
||||
) {
|
||||
ServerTasksView()
|
||||
}
|
||||
|
||||
// MARK: - Users
|
||||
|
||||
static func userAddAccessSchedule(viewModel: ServerUserAdminViewModel) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "userAddAccessSchedule",
|
||||
style: .sheet
|
||||
) {
|
||||
AddAccessScheduleView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
static func userAddAccessTag(viewModel: ServerUserAdminViewModel) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "userAddAccessTag",
|
||||
style: .sheet
|
||||
) {
|
||||
AddServerUserAccessTagsView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
static func userDetails(user: UserDto) -> NavigationRoute {
|
||||
NavigationRoute(id: "userDetails") {
|
||||
ServerUserDetailsView(user: user)
|
||||
}
|
||||
}
|
||||
|
||||
static func userDeviceAccess(viewModel: ServerUserAdminViewModel) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "userDeviceAccess",
|
||||
style: .sheet
|
||||
) {
|
||||
ServerUserDeviceAccessView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
static func userEditAccessSchedules(viewModel: ServerUserAdminViewModel) -> NavigationRoute {
|
||||
NavigationRoute(id: "userEditAccessSchedules") {
|
||||
EditAccessScheduleView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
static func userEditAccessTags(viewModel: ServerUserAdminViewModel) -> NavigationRoute {
|
||||
NavigationRoute(id: "userEditAccessTags") {
|
||||
EditServerUserAccessTagsView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
static func userLiveTVAccess(viewModel: ServerUserAdminViewModel) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "userLiveTVAccess",
|
||||
style: .sheet
|
||||
) {
|
||||
ServerUserLiveTVAccessView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
static func userMediaAccess(viewModel: ServerUserAdminViewModel) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "userMediaAccess",
|
||||
style: .sheet
|
||||
) {
|
||||
ServerUserMediaAccessView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
static func userParentalRatings(viewModel: ServerUserAdminViewModel) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "userParentalRatings",
|
||||
style: .sheet
|
||||
) {
|
||||
ServerUserParentalRatingView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
static func userPermissions(viewModel: ServerUserAdminViewModel) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "userPermissions",
|
||||
style: .sheet
|
||||
) {
|
||||
ServerUserPermissionsView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
static let users = NavigationRoute(
|
||||
id: "users"
|
||||
) {
|
||||
ServerUsersView()
|
||||
}
|
||||
}
|
||||
#endif
|
|
@ -0,0 +1,50 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
extension NavigationRoute {
|
||||
|
||||
#if os(iOS)
|
||||
static let aboutApp = NavigationRoute(
|
||||
id: "about-app"
|
||||
) {
|
||||
AboutAppView()
|
||||
}
|
||||
|
||||
static func appIconSelector(viewModel: SettingsViewModel) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "app-icon-selector"
|
||||
) {
|
||||
AppIconSelectorView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
static let appSettings = NavigationRoute(
|
||||
id: "app-settings",
|
||||
style: .sheet
|
||||
) {
|
||||
AppSettingsView()
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
static let hourPicker = NavigationRoute(
|
||||
id: "hour-picker",
|
||||
style: .fullscreen
|
||||
) {
|
||||
ZStack {
|
||||
BlurView()
|
||||
.ignoresSafeArea()
|
||||
|
||||
HourMinutePicker()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
extension NavigationRoute {
|
||||
|
||||
static let downloadList = NavigationRoute(
|
||||
id: "downloadList"
|
||||
) {
|
||||
#if os(iOS)
|
||||
DownloadListView(viewModel: .init())
|
||||
#else
|
||||
EmptyView()
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
static func downloadTask(downloadTask: DownloadTask) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "downloadTask",
|
||||
style: .sheet
|
||||
) {
|
||||
DownloadTaskView(downloadTask: downloadTask)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
|
@ -0,0 +1,275 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
extension NavigationRoute {
|
||||
|
||||
// MARK: - Item Editing
|
||||
|
||||
#if os(iOS)
|
||||
static func addGenre(viewModel: GenreEditorViewModel) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "addGenre",
|
||||
style: .sheet
|
||||
) {
|
||||
AddItemElementView(viewModel: viewModel, type: .genres)
|
||||
}
|
||||
}
|
||||
|
||||
static func addItemImage(viewModel: ItemImagesViewModel, imageType: ImageType) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "addItemImage",
|
||||
style: .push(.automatic)
|
||||
) {
|
||||
AddItemImageView(
|
||||
viewModel: viewModel,
|
||||
imageType: imageType
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
static func addPeople(viewModel: PeopleEditorViewModel) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "addPeople",
|
||||
style: .sheet
|
||||
) {
|
||||
AddItemElementView(viewModel: viewModel, type: .people)
|
||||
}
|
||||
}
|
||||
|
||||
static func addStudio(viewModel: StudioEditorViewModel) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "addStudio",
|
||||
style: .sheet
|
||||
) {
|
||||
AddItemElementView(viewModel: viewModel, type: .studios)
|
||||
}
|
||||
}
|
||||
|
||||
static func addTag(viewModel: TagEditorViewModel) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "addTag",
|
||||
style: .sheet
|
||||
) {
|
||||
AddItemElementView(viewModel: viewModel, type: .tags)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
static func castAndCrew(people: [BaseItemPerson], itemID: String?) -> NavigationRoute {
|
||||
let id: String? = itemID == nil ? nil : "castAndCrew-\(itemID!)"
|
||||
let viewModel = PagingLibraryViewModel(
|
||||
title: L10n.castAndCrew,
|
||||
id: id,
|
||||
people
|
||||
)
|
||||
|
||||
return NavigationRoute(id: "castAndCrew") {
|
||||
PagingLibraryView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
static func cropItemImage(viewModel: ItemImagesViewModel, image: UIImage, type: ImageType) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "crop-Image"
|
||||
) {
|
||||
ItemPhotoCropView(
|
||||
viewModel: viewModel,
|
||||
image: image,
|
||||
type: type
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
static func editGenres(item: BaseItemDto) -> NavigationRoute {
|
||||
NavigationRoute(id: "editGenres") {
|
||||
EditItemElementView<String>(
|
||||
viewModel: GenreEditorViewModel(item: item),
|
||||
type: .genres,
|
||||
route: { router, viewModel in
|
||||
router.route(to: .addGenre(viewModel: viewModel as! GenreEditorViewModel))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
static func editSubtitles(item: BaseItemDto) -> NavigationRoute {
|
||||
NavigationRoute(id: "editSubtitles") {
|
||||
ItemSubtitlesView(item: item)
|
||||
}
|
||||
}
|
||||
|
||||
static func uploadSubtitle(viewModel: SubtitleEditorViewModel) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "uploadSubtitle",
|
||||
style: .sheet
|
||||
) {
|
||||
ItemSubtitleUploadView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
static func editMetadata(item: BaseItemDto) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "editMetadata",
|
||||
style: .sheet
|
||||
) {
|
||||
EditMetadataView(viewModel: ItemEditorViewModel(item: item))
|
||||
}
|
||||
}
|
||||
|
||||
static func editPeople(item: BaseItemDto) -> NavigationRoute {
|
||||
NavigationRoute(id: "editPeople") {
|
||||
EditItemElementView<BaseItemPerson>(
|
||||
viewModel: PeopleEditorViewModel(item: item),
|
||||
type: .people,
|
||||
route: { router, viewModel in
|
||||
router.route(to: .addPeople(viewModel: viewModel as! PeopleEditorViewModel))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
static func editStudios(item: BaseItemDto) -> NavigationRoute {
|
||||
NavigationRoute(id: "editStudios") {
|
||||
EditItemElementView<NameGuidPair>(
|
||||
viewModel: StudioEditorViewModel(item: item),
|
||||
type: .studios,
|
||||
route: { router, viewModel in
|
||||
router.route(to: .addStudio(viewModel: viewModel as! StudioEditorViewModel))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
static func editTags(item: BaseItemDto) -> NavigationRoute {
|
||||
NavigationRoute(id: "editTags") {
|
||||
EditItemElementView<String>(
|
||||
viewModel: TagEditorViewModel(item: item),
|
||||
type: .tags,
|
||||
route: { router, viewModel in
|
||||
router.route(to: .addTag(viewModel: viewModel as! TagEditorViewModel))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
static func identifyItem(item: BaseItemDto) -> NavigationRoute {
|
||||
NavigationRoute(id: "identifyItem") {
|
||||
IdentifyItemView(item: item)
|
||||
}
|
||||
}
|
||||
|
||||
static func identifyItemResults(
|
||||
viewModel: IdentifyItemViewModel,
|
||||
result: RemoteSearchResult
|
||||
) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "identifyItemResults",
|
||||
style: .sheet
|
||||
) {
|
||||
IdentifyItemView.RemoteSearchResultView(
|
||||
viewModel: viewModel,
|
||||
result: result
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
static func searchSubtitle(viewModel: SubtitleEditorViewModel) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "searchSubtitle",
|
||||
style: .sheet
|
||||
) {
|
||||
ItemSubtitleSearchView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
static func item(item: BaseItemDto) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "item-\(item.id ?? "Unknown")",
|
||||
withNamespace: { .push(.zoom(sourceID: "item", namespace: $0)) }
|
||||
) {
|
||||
ItemView(item: item)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
static func itemEditor(viewModel: ItemViewModel) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "itemEditor",
|
||||
style: .sheet
|
||||
) {
|
||||
ItemEditorView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
static func itemImageDetails(viewModel: ItemImagesViewModel, imageInfo: ImageInfo) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "itemImageDetails",
|
||||
style: .sheet
|
||||
) {
|
||||
ItemImageDetailsView(
|
||||
viewModel: viewModel,
|
||||
imageInfo: imageInfo
|
||||
)
|
||||
.isEditing(true)
|
||||
}
|
||||
}
|
||||
|
||||
static func itemImages(viewModel: ItemImagesViewModel) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "itemImages",
|
||||
style: .sheet
|
||||
) {
|
||||
ItemImagesView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
static func itemImageSelector(viewModel: ItemImagesViewModel, imageType: ImageType) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "itemImageSelector",
|
||||
style: .sheet
|
||||
) {
|
||||
ItemImagePicker(
|
||||
viewModel: viewModel,
|
||||
type: imageType
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
static func itemOverview(item: BaseItemDto) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "itemOverview",
|
||||
style: .sheet
|
||||
) {
|
||||
ItemOverviewView(item: item)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
|
||||
static func itemSearchImageDetails(viewModel: ItemImagesViewModel, remoteImageInfo: RemoteImageInfo) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "itemSearchImageDetails",
|
||||
style: .sheet
|
||||
) {
|
||||
ItemImageDetailsView(
|
||||
viewModel: viewModel,
|
||||
remoteImageInfo: remoteImageInfo
|
||||
)
|
||||
.isEditing(false)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
extension NavigationRoute {
|
||||
|
||||
#if os(iOS)
|
||||
static func filter(type: ItemFilterType, viewModel: FilterViewModel) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "filter",
|
||||
style: .sheet
|
||||
) {
|
||||
FilterView(viewModel: viewModel, type: type)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
static func library(
|
||||
viewModel: PagingLibraryViewModel<some Poster>
|
||||
) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "library-(\(viewModel.parent?.id ?? "Unparented"))",
|
||||
withNamespace: { .push(.zoom(sourceID: "item", namespace: $0)) }
|
||||
) {
|
||||
PagingLibraryView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,121 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import Factory
|
||||
import JellyfinAPI
|
||||
import PreferencesView
|
||||
import SwiftUI
|
||||
import Transmission
|
||||
|
||||
extension NavigationRoute {
|
||||
|
||||
static let channels = NavigationRoute(
|
||||
id: "channels"
|
||||
) {
|
||||
ChannelLibraryView()
|
||||
}
|
||||
|
||||
static let liveTV = NavigationRoute(
|
||||
id: "liveTV"
|
||||
) {
|
||||
ProgramsView()
|
||||
}
|
||||
|
||||
static func mediaSourceInfo(source: MediaSourceInfo) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "mediaSourceInfo",
|
||||
style: .sheet
|
||||
) {
|
||||
MediaSourceInfoView(source: source)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
static func mediaStreamInfo(mediaStream: MediaStream) -> NavigationRoute {
|
||||
NavigationRoute(id: "mediaStreamInfo") {
|
||||
MediaStreamInfoView(mediaStream: mediaStream)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
static func videoPlayer(
|
||||
item: BaseItemDto,
|
||||
mediaSource: MediaSourceInfo? = nil,
|
||||
queue: (any MediaPlayerQueue)? = nil
|
||||
) -> NavigationRoute {
|
||||
let provider = MediaPlayerItemProvider(item: item) { item in
|
||||
try await MediaPlayerItem.build(for: item, mediaSource: mediaSource)
|
||||
}
|
||||
return Self.videoPlayer(provider: provider, queue: queue)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func videoPlayer(
|
||||
provider: MediaPlayerItemProvider,
|
||||
queue: (any MediaPlayerQueue)? = nil
|
||||
) -> NavigationRoute {
|
||||
let manager = MediaPlayerManager(
|
||||
item: provider.item,
|
||||
queue: queue,
|
||||
mediaPlayerItemProvider: provider.function
|
||||
)
|
||||
|
||||
return Self.videoPlayer(manager: manager)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func videoPlayer(manager: MediaPlayerManager) -> NavigationRoute {
|
||||
|
||||
Container.shared.mediaPlayerManager.register {
|
||||
manager
|
||||
}
|
||||
|
||||
Container.shared.mediaPlayerManagerPublisher()
|
||||
.send(manager)
|
||||
|
||||
return NavigationRoute(
|
||||
id: "videoPlayer",
|
||||
style: .fullscreen
|
||||
) {
|
||||
VideoPlayerViewShim(manager: manager)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: shim until native vs swiftfin player is replace with vlc vs av layers
|
||||
// - when removed, ensure same behavior with safe area
|
||||
// - may just need to make a VC wrapper to capture them
|
||||
|
||||
struct VideoPlayerViewShim: View {
|
||||
|
||||
@State
|
||||
private var safeAreaInsets: EdgeInsets = .init()
|
||||
|
||||
let manager: MediaPlayerManager
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin {
|
||||
VideoPlayer()
|
||||
} else {
|
||||
NativeVideoPlayer()
|
||||
}
|
||||
}
|
||||
.colorScheme(.dark) // use over `preferredColorScheme(.dark)` to not have destination change
|
||||
.environment(\.safeAreaInsets, safeAreaInsets)
|
||||
.supportedOrientations(.allButUpsideDown)
|
||||
.ignoresSafeArea()
|
||||
.persistentSystemOverlays(.hidden)
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.onSizeChanged { _, safeArea in
|
||||
self.safeAreaInsets = safeArea.max(EdgeInsets.edgePadding)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,205 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import JellyfinAPI
|
||||
import PulseUI
|
||||
import SwiftUI
|
||||
|
||||
extension NavigationRoute {
|
||||
|
||||
#if os(iOS)
|
||||
static func actionButtonSelector(selectedButtonsBinding: Binding<[VideoPlayerActionButton]>) -> NavigationRoute {
|
||||
NavigationRoute(id: "actionButtonSelector") {
|
||||
ActionButtonSelectorView(selection: selectedButtonsBinding)
|
||||
}
|
||||
}
|
||||
|
||||
static let adminDashboard = NavigationRoute(
|
||||
id: "adminDashboard"
|
||||
) {
|
||||
AdminDashboardView()
|
||||
}
|
||||
#endif
|
||||
|
||||
static let createCustomDeviceProfile = NavigationRoute(
|
||||
id: "createCustomDeviceProfile",
|
||||
style: .sheet
|
||||
) {
|
||||
CustomDeviceProfileSettingsView.EditCustomDeviceProfileView(profile: nil)
|
||||
.navigationTitle(L10n.customProfile)
|
||||
}
|
||||
|
||||
static let customDeviceProfileSettings = NavigationRoute(
|
||||
id: "customDeviceProfileSettings"
|
||||
) {
|
||||
CustomDeviceProfileSettingsView()
|
||||
}
|
||||
|
||||
static let customizeViewsSettings = NavigationRoute(
|
||||
id: "customizeViewsSettings"
|
||||
) {
|
||||
CustomizeViewsSettings()
|
||||
}
|
||||
|
||||
#if DEBUG && !os(tvOS)
|
||||
static let debugSettings = NavigationRoute(
|
||||
id: "debugSettings"
|
||||
) {
|
||||
DebugSettingsView()
|
||||
}
|
||||
#endif
|
||||
|
||||
static func editCustomDeviceProfile(profile: Binding<CustomDeviceProfile>) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "editCustomDeviceProfile",
|
||||
style: .sheet
|
||||
) {
|
||||
CustomDeviceProfileSettingsView.EditCustomDeviceProfileView(profile: profile)
|
||||
.navigationTitle(L10n.customProfile)
|
||||
}
|
||||
}
|
||||
|
||||
static func editCustomDeviceProfileAudio(selection: Binding<[AudioCodec]>) -> NavigationRoute {
|
||||
NavigationRoute(id: "editCustomDeviceProfileAudio") {
|
||||
OrderedSectionSelectorView(selection: selection, sources: AudioCodec.allCases)
|
||||
.navigationTitle(L10n.audio)
|
||||
}
|
||||
}
|
||||
|
||||
static func editCustomDeviceProfileContainer(selection: Binding<[MediaContainer]>) -> NavigationRoute {
|
||||
NavigationRoute(id: "editCustomDeviceProfileContainer") {
|
||||
OrderedSectionSelectorView(selection: selection, sources: MediaContainer.allCases)
|
||||
.navigationTitle(L10n.containers)
|
||||
}
|
||||
}
|
||||
|
||||
static func editCustomDeviceProfileVideo(selection: Binding<[VideoCodec]>) -> NavigationRoute {
|
||||
NavigationRoute(id: "editCustomDeviceProfileVideo") {
|
||||
OrderedSectionSelectorView(selection: selection, sources: VideoCodec.allCases)
|
||||
.navigationTitle(L10n.video)
|
||||
}
|
||||
}
|
||||
|
||||
static func editServer(server: ServerState, isEditing: Bool = false) -> NavigationRoute {
|
||||
NavigationRoute(id: "editServer") {
|
||||
EditServerView(server: server)
|
||||
.isEditing(isEditing)
|
||||
}
|
||||
}
|
||||
|
||||
static let experimentalSettings = NavigationRoute(
|
||||
id: "experimentalSettings"
|
||||
) {
|
||||
ExperimentalSettingsView()
|
||||
}
|
||||
|
||||
static func fontPicker(selection: Binding<String>) -> NavigationRoute {
|
||||
NavigationRoute(id: "fontPicker") {
|
||||
FontPickerView(selection: selection)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
static let gestureSettings = NavigationRoute(
|
||||
id: "gestureSettings"
|
||||
) {
|
||||
GestureSettingsView()
|
||||
}
|
||||
#endif
|
||||
|
||||
static let indicatorSettings = NavigationRoute(
|
||||
id: "indicatorSettings"
|
||||
) {
|
||||
IndicatorSettingsView()
|
||||
}
|
||||
|
||||
static func itemFilterDrawerSelector(selection: Binding<[ItemFilterType]>) -> NavigationRoute {
|
||||
NavigationRoute(id: "itemFilterDrawerSelector") {
|
||||
OrderedSectionSelectorView(selection: selection, sources: ItemFilterType.allCases)
|
||||
.navigationTitle(L10n.filters)
|
||||
}
|
||||
}
|
||||
|
||||
static func itemOverviewView(item: BaseItemDto) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "itemOverviewView",
|
||||
style: .sheet
|
||||
) {
|
||||
ItemOverviewView(item: item)
|
||||
}
|
||||
}
|
||||
|
||||
static func itemViewAttributes(selection: Binding<[ItemViewAttribute]>) -> NavigationRoute {
|
||||
NavigationRoute(id: "itemViewAttributes") {
|
||||
OrderedSectionSelectorView(selection: selection, sources: ItemViewAttribute.allCases)
|
||||
.navigationTitle(L10n.mediaAttributes.localizedCapitalized)
|
||||
}
|
||||
}
|
||||
|
||||
static let localSecurity = NavigationRoute(
|
||||
id: "localSecurity"
|
||||
) {
|
||||
UserLocalSecurityView()
|
||||
}
|
||||
|
||||
static let log = NavigationRoute(
|
||||
id: "log"
|
||||
) {
|
||||
ConsoleView()
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
static let nativePlayerSettings = NavigationRoute(
|
||||
id: "nativePlayerSettings"
|
||||
) {
|
||||
NativeVideoPlayerSettingsView()
|
||||
}
|
||||
#endif
|
||||
|
||||
static let playbackQualitySettings = NavigationRoute(
|
||||
id: "playbackQualitySettings"
|
||||
) {
|
||||
PlaybackQualitySettingsView()
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
static func resetUserPassword(userID: String) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "resetUserPassword",
|
||||
style: .sheet
|
||||
) {
|
||||
ResetUserPasswordView(userID: userID, requiresCurrentPassword: true)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
static func serverConnection(server: ServerState) -> NavigationRoute {
|
||||
NavigationRoute(id: "serverConnection") {
|
||||
EditServerView(server: server)
|
||||
}
|
||||
}
|
||||
|
||||
static let settings = NavigationRoute(
|
||||
id: "settings",
|
||||
style: .sheet
|
||||
) {
|
||||
SettingsView()
|
||||
}
|
||||
|
||||
static func userProfile(viewModel: SettingsViewModel) -> NavigationRoute {
|
||||
NavigationRoute(id: "userProfile") {
|
||||
UserProfileSettingsView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
static let videoPlayerSettings = NavigationRoute(
|
||||
id: "videoPlayerSettings"
|
||||
) {
|
||||
VideoPlayerSettingsView()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
extension NavigationRoute {
|
||||
|
||||
static let connectToServer = NavigationRoute(
|
||||
id: "connectToServer",
|
||||
style: .sheet
|
||||
) {
|
||||
ConnectToServerView()
|
||||
}
|
||||
|
||||
static func quickConnect(quickConnect: QuickConnect) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "quickConnectView",
|
||||
style: .sheet
|
||||
) {
|
||||
QuickConnectView(quickConnect: quickConnect)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
static func userProfileImage(viewModel: UserProfileImageViewModel) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "userProfileImage",
|
||||
style: .sheet
|
||||
) {
|
||||
UserProfileImagePickerView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
static func userProfileImageCrop(viewModel: UserProfileImageViewModel, image: UIImage) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "cropImage",
|
||||
style: .sheet
|
||||
) {
|
||||
UserProfileImageCropView(
|
||||
viewModel: viewModel,
|
||||
image: image
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: rename to `localUserAccessPolicy`
|
||||
static func userSecurity(pinHint: Binding<String>, accessPolicy: Binding<UserAccessPolicy>) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "userSecurity",
|
||||
style: .sheet
|
||||
) {
|
||||
LocalUserAccessPolicyView(
|
||||
pinHint: pinHint,
|
||||
accessPolicy: accessPolicy
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
static func userSignIn(server: ServerState) -> NavigationRoute {
|
||||
NavigationRoute(
|
||||
id: "userSignIn",
|
||||
style: .sheet
|
||||
) {
|
||||
WithUserAuthentication {
|
||||
WithQuickConnect {
|
||||
UserSignInView(server: server)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,87 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct NavigationRoute: Identifiable, Hashable {
|
||||
|
||||
enum TransitionStyle: Hashable {
|
||||
|
||||
// TODO: sheet and fullscreen with `NavigationTransition`
|
||||
case push(NavigationTransition)
|
||||
case sheet
|
||||
case fullscreen
|
||||
}
|
||||
|
||||
enum TransitionType {
|
||||
|
||||
case automatic(TransitionStyle)
|
||||
case withNamespace((Namespace.ID) -> TransitionStyle)
|
||||
}
|
||||
|
||||
let id: String
|
||||
|
||||
private let content: AnyView
|
||||
var transitionType: TransitionType
|
||||
var namespace: Namespace.ID?
|
||||
|
||||
var transitionStyle: TransitionStyle {
|
||||
switch transitionType {
|
||||
case let .automatic(style):
|
||||
return style
|
||||
case let .withNamespace(builder):
|
||||
if let namespace {
|
||||
return builder(namespace)
|
||||
} else {
|
||||
return .push(.automatic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init(
|
||||
id: String,
|
||||
style: TransitionStyle = .push(.automatic),
|
||||
@ViewBuilder content: () -> some View
|
||||
) {
|
||||
self.id = id
|
||||
self.transitionType = .automatic(style)
|
||||
self.namespace = nil
|
||||
self.content = AnyView(content())
|
||||
}
|
||||
|
||||
init(
|
||||
id: String,
|
||||
withNamespace: @escaping (Namespace.ID) -> TransitionStyle,
|
||||
@ViewBuilder content: () -> some View
|
||||
) {
|
||||
self.id = id
|
||||
self.transitionType = .withNamespace(withNamespace)
|
||||
self.namespace = nil
|
||||
self.content = AnyView(content())
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var destination: some View {
|
||||
if case let .push(style) = transitionStyle {
|
||||
content
|
||||
.backport
|
||||
.navigationTransition(style)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,101 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension NavigationCoordinator {
|
||||
|
||||
@MainActor
|
||||
struct Router {
|
||||
|
||||
let navigationCoordinator: NavigationCoordinator?
|
||||
let rootCoordinator: RootCoordinator?
|
||||
|
||||
func route(
|
||||
to route: NavigationRoute,
|
||||
transition: NavigationRoute.TransitionType? = nil,
|
||||
in namespace: Namespace.ID? = nil
|
||||
) {
|
||||
var route = route
|
||||
route.namespace = namespace
|
||||
route.transitionType = transition ?? route.transitionType
|
||||
navigationCoordinator?.push(route)
|
||||
}
|
||||
|
||||
func root(
|
||||
_ root: RootItem
|
||||
) {
|
||||
rootCoordinator?.root(root)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@propertyWrapper
|
||||
struct Router: DynamicProperty {
|
||||
|
||||
@MainActor
|
||||
struct Wrapper {
|
||||
let router: NavigationCoordinator.Router
|
||||
let dismiss: DismissAction
|
||||
|
||||
func route(
|
||||
to route: NavigationRoute,
|
||||
in namespace: Namespace.ID? = nil
|
||||
) {
|
||||
router.route(
|
||||
to: route,
|
||||
transition: nil,
|
||||
in: namespace
|
||||
)
|
||||
}
|
||||
|
||||
func route(
|
||||
to route: NavigationRoute,
|
||||
style: NavigationRoute.TransitionStyle,
|
||||
in namespace: Namespace.ID? = nil
|
||||
) {
|
||||
router.route(
|
||||
to: route,
|
||||
transition: .automatic(style),
|
||||
in: namespace
|
||||
)
|
||||
}
|
||||
|
||||
func route(
|
||||
to route: NavigationRoute,
|
||||
withNamespace: @escaping (Namespace.ID) -> NavigationRoute.TransitionStyle,
|
||||
in namespace: Namespace.ID? = nil
|
||||
) {
|
||||
router.route(
|
||||
to: route,
|
||||
transition: .withNamespace(withNamespace),
|
||||
in: namespace
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// `.dismiss` causes changes on disappear
|
||||
@Environment(\.self)
|
||||
private var environment
|
||||
|
||||
var wrappedValue: Wrapper {
|
||||
.init(
|
||||
router: environment.router,
|
||||
dismiss: environment.dismiss
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
|
||||
@Entry
|
||||
var router: NavigationCoordinator.Router = .init(
|
||||
navigationCoordinator: nil,
|
||||
rootCoordinator: nil
|
||||
)
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
import Transmission
|
||||
|
||||
// TODO: sometimes causes hangs?
|
||||
|
||||
struct WithTransitionReaderPublisher<Content: View>: View {
|
||||
|
||||
@StateObject
|
||||
private var publishedBox: PublishedBox<LegacyEventPublisher<TransitionReaderProxy?>> = .init(initialValue: .init())
|
||||
|
||||
let content: Content
|
||||
|
||||
init(@ViewBuilder content: @escaping () -> Content) {
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.environment(\.transitionReader, publishedBox.value)
|
||||
.background {
|
||||
TransitionReader { proxy in
|
||||
Color.clear
|
||||
.onChange(of: proxy) { newValue in
|
||||
publishedBox.value.send(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@propertyWrapper
|
||||
struct TransitionReaderObserver: DynamicProperty {
|
||||
|
||||
@Environment(\.transitionReader)
|
||||
private var publisher
|
||||
|
||||
var wrappedValue: LegacyEventPublisher<TransitionReaderProxy?> {
|
||||
publisher
|
||||
}
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
|
||||
@Entry
|
||||
var transitionReader: LegacyEventPublisher<TransitionReaderProxy?> = .init()
|
||||
}
|
||||
#endif
|
|
@ -0,0 +1,86 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import Factory
|
||||
import Logging
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class RootCoordinator: ObservableObject {
|
||||
|
||||
@Published
|
||||
var root: RootItem = .appLoading
|
||||
|
||||
private let logger = Logger.swiftfin()
|
||||
|
||||
init() {
|
||||
Task {
|
||||
do {
|
||||
try await SwiftfinStore.setupDataStack()
|
||||
|
||||
if Container.shared.currentUserSession() != nil, !Defaults[.signOutOnClose] {
|
||||
#if os(tvOS)
|
||||
await MainActor.run {
|
||||
root(.mainTab)
|
||||
}
|
||||
#else
|
||||
await MainActor.run {
|
||||
root(.serverCheck)
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
await MainActor.run {
|
||||
root(.selectUser)
|
||||
}
|
||||
}
|
||||
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
Notifications[.didFailMigration].post()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notification setup for state
|
||||
Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn))
|
||||
Notifications[.didSignOut].subscribe(self, selector: #selector(didSignOut))
|
||||
Notifications[.didChangeCurrentServerURL].subscribe(self, selector: #selector(didChangeCurrentServerURL(_:)))
|
||||
}
|
||||
|
||||
func root(_ newRoot: RootItem) {
|
||||
root = newRoot
|
||||
}
|
||||
|
||||
@objc
|
||||
private func didSignIn() {
|
||||
logger.info("Signed in")
|
||||
|
||||
#if os(tvOS)
|
||||
root(.mainTab)
|
||||
#else
|
||||
root(.serverCheck)
|
||||
#endif
|
||||
}
|
||||
|
||||
@objc
|
||||
private func didSignOut() {
|
||||
logger.info("Signed out")
|
||||
|
||||
root(.selectUser)
|
||||
}
|
||||
|
||||
@objc
|
||||
func didChangeCurrentServerURL(_ notification: Notification) {
|
||||
|
||||
guard Container.shared.currentUserSession() != nil else { return }
|
||||
|
||||
Container.shared.currentUserSession.reset()
|
||||
Notifications[.didSignIn].post()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct RootItem: Identifiable {
|
||||
|
||||
var id: String
|
||||
let content: AnyView
|
||||
|
||||
init(
|
||||
id: String,
|
||||
@ViewBuilder content: () -> some View
|
||||
) {
|
||||
self.id = id
|
||||
self.content = AnyView(content())
|
||||
}
|
||||
|
||||
static let appLoading = RootItem(id: "appLoading") {
|
||||
NavigationInjectionView(coordinator: .init()) {
|
||||
AppLoadingView()
|
||||
}
|
||||
}
|
||||
|
||||
static let mainTab = RootItem(id: "mainTab") {
|
||||
MainTabView()
|
||||
}
|
||||
|
||||
static let selectUser = RootItem(id: "selectUser") {
|
||||
NavigationInjectionView(coordinator: .init()) {
|
||||
SelectUserView()
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
static let serverCheck = RootItem(id: "serverCheck") {
|
||||
NavigationInjectionView(coordinator: .init()) {
|
||||
ServerCheckView()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Transmission
|
||||
|
||||
// Status bar presentation needs to happen at this level
|
||||
struct RootView: View {
|
||||
|
||||
@State
|
||||
private var isStatusBarHidden: Bool = false
|
||||
|
||||
@StateObject
|
||||
private var rootCoordinator: RootCoordinator = .init()
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if rootCoordinator.root.id == RootItem.appLoading.id {
|
||||
RootItem.appLoading.content
|
||||
}
|
||||
|
||||
if rootCoordinator.root.id == RootItem.mainTab.id {
|
||||
RootItem.mainTab.content
|
||||
}
|
||||
|
||||
if rootCoordinator.root.id == RootItem.selectUser.id {
|
||||
RootItem.selectUser.content
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
if rootCoordinator.root.id == RootItem.serverCheck.id {
|
||||
RootItem.serverCheck.content
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.animation(.linear(duration: 0.1), value: rootCoordinator.root.id)
|
||||
.environmentObject(rootCoordinator)
|
||||
.prefersStatusBarHidden(isStatusBarHidden)
|
||||
.onPreferenceChange(IsStatusBarHiddenKey.self) { newValue in
|
||||
isStatusBarHidden = newValue
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
|
||||
final class SearchCoordinator: NavigationCoordinatable {
|
||||
|
||||
let stack = NavigationStack(initial: \SearchCoordinator.start)
|
||||
|
||||
@Root
|
||||
var start = makeStart
|
||||
#if os(tvOS)
|
||||
@Route(.push)
|
||||
var item = makeItem
|
||||
@Route(.push)
|
||||
var library = makeLibrary
|
||||
#else
|
||||
@Route(.push)
|
||||
var item = makeItem
|
||||
@Route(.push)
|
||||
var library = makeLibrary
|
||||
@Route(.modal)
|
||||
var filter = makeFilter
|
||||
#endif
|
||||
|
||||
func makeItem(item: BaseItemDto) -> ItemCoordinator {
|
||||
ItemCoordinator(item: item)
|
||||
}
|
||||
|
||||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
|
||||
LibraryCoordinator(viewModel: viewModel)
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator<FilterCoordinator> {
|
||||
NavigationViewCoordinator(FilterCoordinator(parameters: parameters))
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder
|
||||
func makeStart() -> some View {
|
||||
SearchView()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
|
||||
final class SearchCoordinator: NavigationCoordinatable {
|
||||
|
||||
let stack = NavigationStack(initial: \SearchCoordinator.start)
|
||||
|
||||
@Root
|
||||
var start = makeStart
|
||||
#if os(tvOS)
|
||||
@Route(.push)
|
||||
var item = makeItem
|
||||
@Route(.push)
|
||||
var library = makeLibrary
|
||||
#else
|
||||
@Route(.push)
|
||||
var item = makeItem
|
||||
@Route(.push)
|
||||
var library = makeLibrary
|
||||
@Route(.modal)
|
||||
var filter = makeFilter
|
||||
#endif
|
||||
|
||||
func makeItem(item: BaseItemDto) -> ItemCoordinator {
|
||||
ItemCoordinator(item: item)
|
||||
}
|
||||
|
||||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
|
||||
LibraryCoordinator(viewModel: viewModel)
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator<FilterCoordinator> {
|
||||
NavigationViewCoordinator(FilterCoordinator(parameters: parameters))
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder
|
||||
func makeStart() -> some View {
|
||||
SearchView()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
|
||||
final class SelectUserCoordinator: NavigationCoordinatable {
|
||||
|
||||
let stack = NavigationStack(initial: \SelectUserCoordinator.start)
|
||||
|
||||
@Root
|
||||
var start = makeStart
|
||||
|
||||
@Route(.push)
|
||||
var advancedSettings = makeAdvancedSettings
|
||||
@Route(.push)
|
||||
var connectToServer = makeConnectToServer
|
||||
@Route(.push)
|
||||
var editServer = makeEditServer
|
||||
@Route(.push)
|
||||
var userSignIn = makeUserSignIn
|
||||
|
||||
func makeAdvancedSettings() -> NavigationViewCoordinator<AppSettingsCoordinator> {
|
||||
NavigationViewCoordinator(AppSettingsCoordinator())
|
||||
}
|
||||
|
||||
func makeConnectToServer() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
NavigationViewCoordinator {
|
||||
ConnectToServerView()
|
||||
}
|
||||
}
|
||||
|
||||
func makeEditServer(server: ServerState) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
NavigationViewCoordinator {
|
||||
EditServerView(server: server)
|
||||
.environment(\.isEditing, true)
|
||||
#if os(iOS)
|
||||
.navigationBarCloseButton {
|
||||
self.popLast()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func makeUserSignIn(server: ServerState) -> NavigationViewCoordinator<UserSignInCoordinator> {
|
||||
NavigationViewCoordinator(UserSignInCoordinator(server: server))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeStart() -> some View {
|
||||
SelectUserView()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
|
||||
final class SelectUserCoordinator: NavigationCoordinatable {
|
||||
|
||||
let stack = NavigationStack(initial: \SelectUserCoordinator.start)
|
||||
|
||||
@Root
|
||||
var start = makeStart
|
||||
|
||||
@Route(.push)
|
||||
var advancedSettings = makeAdvancedSettings
|
||||
@Route(.push)
|
||||
var connectToServer = makeConnectToServer
|
||||
@Route(.push)
|
||||
var editServer = makeEditServer
|
||||
@Route(.push)
|
||||
var userSignIn = makeUserSignIn
|
||||
|
||||
func makeAdvancedSettings() -> NavigationViewCoordinator<AppSettingsCoordinator> {
|
||||
NavigationViewCoordinator(AppSettingsCoordinator())
|
||||
}
|
||||
|
||||
func makeConnectToServer() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
NavigationViewCoordinator {
|
||||
ConnectToServerView()
|
||||
}
|
||||
}
|
||||
|
||||
func makeEditServer(server: ServerState) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
NavigationViewCoordinator {
|
||||
EditServerView(server: server)
|
||||
.environment(\.isEditing, true)
|
||||
#if os(iOS)
|
||||
.navigationBarCloseButton {
|
||||
self.popLast()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func makeUserSignIn(server: ServerState) -> NavigationViewCoordinator<UserSignInCoordinator> {
|
||||
NavigationViewCoordinator(UserSignInCoordinator(server: server))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeStart() -> some View {
|
||||
SelectUserView()
|
||||
}
|
||||
}
|
|
@ -22,6 +22,10 @@ final class SelectUserCoordinator: NavigationCoordinatable {
|
|||
@Route(.push)
|
||||
var connectToServer = makeConnectToServer
|
||||
@Route(.push)
|
||||
var connectToXtream = makeConnectToXtream
|
||||
@Route(.push)
|
||||
var dualServerConnect = makeDualServerConnect
|
||||
@Route(.push)
|
||||
var editServer = makeEditServer
|
||||
@Route(.push)
|
||||
var userSignIn = makeUserSignIn
|
||||
|
@ -30,10 +34,19 @@ final class SelectUserCoordinator: NavigationCoordinatable {
|
|||
NavigationViewCoordinator(AppSettingsCoordinator())
|
||||
}
|
||||
|
||||
func makeConnectToServer() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
NavigationViewCoordinator {
|
||||
@ViewBuilder
|
||||
func makeConnectToServer() -> some View {
|
||||
ConnectToServerView()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeConnectToXtream() -> some View {
|
||||
ConnectToXtreamView()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeDualServerConnect() -> some View {
|
||||
DualServerConnectView()
|
||||
}
|
||||
|
||||
func makeEditServer(server: ServerState) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
|
|
|
@ -0,0 +1,263 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import JellyfinAPI
|
||||
import PulseUI
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
|
||||
final class SettingsCoordinator: NavigationCoordinatable {
|
||||
let stack = NavigationStack(initial: \SettingsCoordinator.start)
|
||||
|
||||
@Root
|
||||
var start = makeStart
|
||||
|
||||
#if os(iOS)
|
||||
@Route(.push)
|
||||
var log = makeLog
|
||||
@Route(.push)
|
||||
var nativePlayerSettings = makeNativePlayerSettings
|
||||
@Route(.push)
|
||||
var playbackQualitySettings = makePlaybackQualitySettings
|
||||
@Route(.push)
|
||||
var quickConnect = makeQuickConnectAuthorize
|
||||
@Route(.push)
|
||||
var resetUserPassword = makeResetUserPassword
|
||||
@Route(.push)
|
||||
var localSecurity = makeLocalSecurity
|
||||
@Route(.push)
|
||||
var photoPicker = makePhotoPicker
|
||||
@Route(.push)
|
||||
var userProfile = makeUserProfileSettings
|
||||
|
||||
@Route(.push)
|
||||
var customizeViewsSettings = makeCustomizeViewsSettings
|
||||
@Route(.push)
|
||||
var experimentalSettings = makeExperimentalSettings
|
||||
@Route(.push)
|
||||
var itemFilterDrawerSelector = makeItemFilterDrawerSelector
|
||||
@Route(.push)
|
||||
var indicatorSettings = makeIndicatorSettings
|
||||
@Route(.push)
|
||||
var itemViewAttributes = makeItemViewAttributes
|
||||
@Route(.push)
|
||||
var serverConnection = makeServerConnection
|
||||
@Route(.push)
|
||||
var videoPlayerSettings = makeVideoPlayerSettings
|
||||
@Route(.push)
|
||||
var customDeviceProfileSettings = makeCustomDeviceProfileSettings
|
||||
@Route(.push)
|
||||
var itemOverviewView = makeItemOverviewView
|
||||
|
||||
@Route(.push)
|
||||
var editCustomDeviceProfile = makeEditCustomDeviceProfile
|
||||
@Route(.push)
|
||||
var createCustomDeviceProfile = makeCreateCustomDeviceProfile
|
||||
|
||||
@Route(.push)
|
||||
var adminDashboard = makeAdminDashboard
|
||||
|
||||
#if DEBUG
|
||||
@Route(.push)
|
||||
var debugSettings = makeDebugSettings
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
@Route(.push)
|
||||
var customizeViewsSettings = makeCustomizeViewsSettings
|
||||
@Route(.push)
|
||||
var experimentalSettings = makeExperimentalSettings
|
||||
@Route(.push)
|
||||
var log = makeLog
|
||||
@Route(.push)
|
||||
var serverDetail = makeServerDetail
|
||||
@Route(.push)
|
||||
var videoPlayerSettings = makeVideoPlayerSettings
|
||||
@Route(.push)
|
||||
var playbackQualitySettings = makePlaybackQualitySettings
|
||||
@Route(.push)
|
||||
var userProfile = makeUserProfileSettings
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
@ViewBuilder
|
||||
func makeNativePlayerSettings() -> some View {
|
||||
NativeVideoPlayerSettingsView()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makePlaybackQualitySettings() -> some View {
|
||||
PlaybackQualitySettingsView()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeCustomDeviceProfileSettings() -> some View {
|
||||
CustomDeviceProfileSettingsView()
|
||||
}
|
||||
|
||||
func makeEditCustomDeviceProfile(profile: Binding<CustomDeviceProfile>)
|
||||
-> NavigationViewCoordinator<EditCustomDeviceProfileCoordinator>
|
||||
{
|
||||
NavigationViewCoordinator(EditCustomDeviceProfileCoordinator(profile: profile))
|
||||
}
|
||||
|
||||
func makeCreateCustomDeviceProfile() -> NavigationViewCoordinator<EditCustomDeviceProfileCoordinator> {
|
||||
NavigationViewCoordinator(EditCustomDeviceProfileCoordinator())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeQuickConnectAuthorize(user: UserDto) -> some View {
|
||||
QuickConnectAuthorizeView(user: user)
|
||||
}
|
||||
|
||||
func makeResetUserPassword(userID: String) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
NavigationViewCoordinator {
|
||||
ResetUserPasswordView(userID: userID, requiresCurrentPassword: true)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeLocalSecurity() -> some View {
|
||||
UserLocalSecurityView()
|
||||
}
|
||||
|
||||
func makePhotoPicker(viewModel: UserProfileImageViewModel) -> NavigationViewCoordinator<UserProfileImageCoordinator> {
|
||||
NavigationViewCoordinator(UserProfileImageCoordinator(viewModel: viewModel))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeUserProfileSettings(viewModel: SettingsViewModel) -> some View {
|
||||
UserProfileSettingsView(viewModel: viewModel)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeCustomizeViewsSettings() -> some View {
|
||||
CustomizeViewsSettings()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeExperimentalSettings() -> some View {
|
||||
ExperimentalSettingsView()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeIndicatorSettings() -> some View {
|
||||
IndicatorSettingsView()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeItemViewAttributes(selection: Binding<[ItemViewAttribute]>) -> some View {
|
||||
OrderedSectionSelectorView(selection: selection, sources: ItemViewAttribute.allCases)
|
||||
.navigationTitle(L10n.mediaAttributes.localizedCapitalized)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeServerConnection(server: ServerState) -> some View {
|
||||
EditServerView(server: server)
|
||||
}
|
||||
|
||||
func makeItemOverviewView(item: BaseItemDto) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
NavigationViewCoordinator {
|
||||
ItemOverviewView(item: item)
|
||||
}
|
||||
}
|
||||
|
||||
func makeItemFilterDrawerSelector(selection: Binding<[ItemFilterType]>) -> some View {
|
||||
OrderedSectionSelectorView(selection: selection, sources: ItemFilterType.allCases)
|
||||
.navigationTitle(L10n.filters)
|
||||
}
|
||||
|
||||
func makeVideoPlayerSettings() -> VideoPlayerSettingsCoordinator {
|
||||
VideoPlayerSettingsCoordinator()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeAdminDashboard() -> some View {
|
||||
AdminDashboardCoordinator().view()
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
@ViewBuilder
|
||||
func makeDebugSettings() -> some View {
|
||||
DebugSettingsView()
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
|
||||
// MARK: - User Profile View
|
||||
|
||||
func makeUserProfileSettings(viewModel: SettingsViewModel) -> NavigationViewCoordinator<UserProfileSettingsCoordinator> {
|
||||
NavigationViewCoordinator(
|
||||
UserProfileSettingsCoordinator(viewModel: viewModel)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Customize Settings View
|
||||
|
||||
func makeCustomizeViewsSettings() -> NavigationViewCoordinator<CustomizeSettingsCoordinator> {
|
||||
NavigationViewCoordinator(
|
||||
CustomizeSettingsCoordinator()
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Experimental Settings View
|
||||
|
||||
func makeExperimentalSettings() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
NavigationViewCoordinator(
|
||||
BasicNavigationViewCoordinator {
|
||||
ExperimentalSettingsView()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Poster Indicator Settings View
|
||||
|
||||
func makeIndicatorSettings() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
NavigationViewCoordinator {
|
||||
IndicatorSettingsView()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Server Settings View
|
||||
|
||||
func makeServerDetail(server: ServerState) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
NavigationViewCoordinator {
|
||||
EditServerView(server: server)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Video Player Settings View
|
||||
|
||||
func makeVideoPlayerSettings() -> NavigationViewCoordinator<VideoPlayerSettingsCoordinator> {
|
||||
NavigationViewCoordinator(
|
||||
VideoPlayerSettingsCoordinator()
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Playback Settings View
|
||||
|
||||
func makePlaybackQualitySettings() -> NavigationViewCoordinator<PlaybackQualitySettingsCoordinator> {
|
||||
NavigationViewCoordinator(
|
||||
PlaybackQualitySettingsCoordinator()
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder
|
||||
func makeLog() -> some View {
|
||||
ConsoleView()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeStart() -> some View {
|
||||
SettingsView()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,263 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import JellyfinAPI
|
||||
import PulseUI
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
|
||||
final class SettingsCoordinator: NavigationCoordinatable {
|
||||
let stack = NavigationStack(initial: \SettingsCoordinator.start)
|
||||
|
||||
@Root
|
||||
var start = makeStart
|
||||
|
||||
#if os(iOS)
|
||||
@Route(.push)
|
||||
var log = makeLog
|
||||
@Route(.push)
|
||||
var nativePlayerSettings = makeNativePlayerSettings
|
||||
@Route(.push)
|
||||
var playbackQualitySettings = makePlaybackQualitySettings
|
||||
@Route(.push)
|
||||
var quickConnect = makeQuickConnectAuthorize
|
||||
@Route(.push)
|
||||
var resetUserPassword = makeResetUserPassword
|
||||
@Route(.push)
|
||||
var localSecurity = makeLocalSecurity
|
||||
@Route(.push)
|
||||
var photoPicker = makePhotoPicker
|
||||
@Route(.push)
|
||||
var userProfile = makeUserProfileSettings
|
||||
|
||||
@Route(.push)
|
||||
var customizeViewsSettings = makeCustomizeViewsSettings
|
||||
@Route(.push)
|
||||
var experimentalSettings = makeExperimentalSettings
|
||||
@Route(.push)
|
||||
var itemFilterDrawerSelector = makeItemFilterDrawerSelector
|
||||
@Route(.push)
|
||||
var indicatorSettings = makeIndicatorSettings
|
||||
@Route(.push)
|
||||
var itemViewAttributes = makeItemViewAttributes
|
||||
@Route(.push)
|
||||
var serverConnection = makeServerConnection
|
||||
@Route(.push)
|
||||
var videoPlayerSettings = makeVideoPlayerSettings
|
||||
@Route(.push)
|
||||
var customDeviceProfileSettings = makeCustomDeviceProfileSettings
|
||||
@Route(.push)
|
||||
var itemOverviewView = makeItemOverviewView
|
||||
|
||||
@Route(.push)
|
||||
var editCustomDeviceProfile = makeEditCustomDeviceProfile
|
||||
@Route(.push)
|
||||
var createCustomDeviceProfile = makeCreateCustomDeviceProfile
|
||||
|
||||
@Route(.push)
|
||||
var adminDashboard = makeAdminDashboard
|
||||
|
||||
#if DEBUG
|
||||
@Route(.push)
|
||||
var debugSettings = makeDebugSettings
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
@Route(.push)
|
||||
var customizeViewsSettings = makeCustomizeViewsSettings
|
||||
@Route(.push)
|
||||
var experimentalSettings = makeExperimentalSettings
|
||||
@Route(.push)
|
||||
var log = makeLog
|
||||
@Route(.push)
|
||||
var serverDetail = makeServerDetail
|
||||
@Route(.push)
|
||||
var videoPlayerSettings = makeVideoPlayerSettings
|
||||
@Route(.push)
|
||||
var playbackQualitySettings = makePlaybackQualitySettings
|
||||
@Route(.push)
|
||||
var userProfile = makeUserProfileSettings
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
@ViewBuilder
|
||||
func makeNativePlayerSettings() -> some View {
|
||||
NativeVideoPlayerSettingsView()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makePlaybackQualitySettings() -> some View {
|
||||
PlaybackQualitySettingsView()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeCustomDeviceProfileSettings() -> some View {
|
||||
CustomDeviceProfileSettingsView()
|
||||
}
|
||||
|
||||
func makeEditCustomDeviceProfile(profile: Binding<CustomDeviceProfile>)
|
||||
-> NavigationViewCoordinator<EditCustomDeviceProfileCoordinator>
|
||||
{
|
||||
NavigationViewCoordinator(EditCustomDeviceProfileCoordinator(profile: profile))
|
||||
}
|
||||
|
||||
func makeCreateCustomDeviceProfile() -> NavigationViewCoordinator<EditCustomDeviceProfileCoordinator> {
|
||||
NavigationViewCoordinator(EditCustomDeviceProfileCoordinator())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeQuickConnectAuthorize(user: UserDto) -> some View {
|
||||
QuickConnectAuthorizeView(user: user)
|
||||
}
|
||||
|
||||
func makeResetUserPassword(userID: String) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
NavigationViewCoordinator {
|
||||
ResetUserPasswordView(userID: userID, requiresCurrentPassword: true)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeLocalSecurity() -> some View {
|
||||
UserLocalSecurityView()
|
||||
}
|
||||
|
||||
func makePhotoPicker(viewModel: UserProfileImageViewModel) -> NavigationViewCoordinator<UserProfileImageCoordinator> {
|
||||
NavigationViewCoordinator(UserProfileImageCoordinator(viewModel: viewModel))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeUserProfileSettings(viewModel: SettingsViewModel) -> some View {
|
||||
UserProfileSettingsView(viewModel: viewModel)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeCustomizeViewsSettings() -> some View {
|
||||
CustomizeViewsSettings()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeExperimentalSettings() -> some View {
|
||||
ExperimentalSettingsView()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeIndicatorSettings() -> some View {
|
||||
IndicatorSettingsView()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeItemViewAttributes(selection: Binding<[ItemViewAttribute]>) -> some View {
|
||||
OrderedSectionSelectorView(selection: selection, sources: ItemViewAttribute.allCases)
|
||||
.navigationTitle(L10n.mediaAttributes.localizedCapitalized)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeServerConnection(server: ServerState) -> some View {
|
||||
EditServerView(server: server)
|
||||
}
|
||||
|
||||
func makeItemOverviewView(item: BaseItemDto) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
NavigationViewCoordinator {
|
||||
ItemOverviewView(item: item)
|
||||
}
|
||||
}
|
||||
|
||||
func makeItemFilterDrawerSelector(selection: Binding<[ItemFilterType]>) -> some View {
|
||||
OrderedSectionSelectorView(selection: selection, sources: ItemFilterType.allCases)
|
||||
.navigationTitle(L10n.filters)
|
||||
}
|
||||
|
||||
func makeVideoPlayerSettings() -> VideoPlayerSettingsCoordinator {
|
||||
VideoPlayerSettingsCoordinator()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeAdminDashboard() -> some View {
|
||||
AdminDashboardCoordinator().view()
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
@ViewBuilder
|
||||
func makeDebugSettings() -> some View {
|
||||
DebugSettingsView()
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
|
||||
// MARK: - User Profile View
|
||||
|
||||
func makeUserProfileSettings(viewModel: SettingsViewModel) -> NavigationViewCoordinator<UserProfileSettingsCoordinator> {
|
||||
NavigationViewCoordinator(
|
||||
UserProfileSettingsCoordinator(viewModel: viewModel)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Customize Settings View
|
||||
|
||||
func makeCustomizeViewsSettings() -> NavigationViewCoordinator<CustomizeSettingsCoordinator> {
|
||||
NavigationViewCoordinator(
|
||||
CustomizeSettingsCoordinator()
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Experimental Settings View
|
||||
|
||||
func makeExperimentalSettings() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
NavigationViewCoordinator(
|
||||
BasicNavigationViewCoordinator {
|
||||
ExperimentalSettingsView()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Poster Indicator Settings View
|
||||
|
||||
func makeIndicatorSettings() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
NavigationViewCoordinator {
|
||||
IndicatorSettingsView()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Server Settings View
|
||||
|
||||
func makeServerDetail(server: ServerState) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
NavigationViewCoordinator {
|
||||
EditServerView(server: server)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Video Player Settings View
|
||||
|
||||
func makeVideoPlayerSettings() -> NavigationViewCoordinator<VideoPlayerSettingsCoordinator> {
|
||||
NavigationViewCoordinator(
|
||||
VideoPlayerSettingsCoordinator()
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Playback Settings View
|
||||
|
||||
func makePlaybackQualitySettings() -> NavigationViewCoordinator<PlaybackQualitySettingsCoordinator> {
|
||||
NavigationViewCoordinator(
|
||||
PlaybackQualitySettingsCoordinator()
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder
|
||||
func makeLog() -> some View {
|
||||
ConsoleView()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeStart() -> some View {
|
||||
SettingsView()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Factory
|
||||
import SwiftUI
|
||||
|
||||
// TODO: move popup to router
|
||||
// - or, make tab view environment object
|
||||
|
||||
// TODO: fix weird tvOS icon rendering
|
||||
struct MainTabView: View {
|
||||
|
||||
#if os(iOS)
|
||||
@StateObject
|
||||
private var tabCoordinator = TabCoordinator {
|
||||
TabItem.home
|
||||
TabItem.search
|
||||
TabItem.media
|
||||
}
|
||||
#else
|
||||
@StateObject
|
||||
private var tabCoordinator = TabCoordinator {
|
||||
TabItem.home
|
||||
TabItem.library(
|
||||
title: L10n.tvShows,
|
||||
systemName: "tv",
|
||||
filters: .init(itemTypes: [.series])
|
||||
)
|
||||
TabItem.library(
|
||||
title: L10n.movies,
|
||||
systemName: "film",
|
||||
filters: .init(itemTypes: [.movie])
|
||||
)
|
||||
TabItem.search
|
||||
TabItem.media
|
||||
TabItem.settings
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
TabView(selection: $tabCoordinator.selectedTabID) {
|
||||
ForEach(tabCoordinator.tabs, id: \.item.id) { tab in
|
||||
NavigationInjectionView(
|
||||
coordinator: tab.coordinator
|
||||
) {
|
||||
tab.item.content
|
||||
}
|
||||
.environmentObject(tabCoordinator)
|
||||
.environment(\.tabItemSelected, tab.publisher)
|
||||
.tabItem {
|
||||
Label(
|
||||
tab.item.title,
|
||||
systemImage: tab.item.systemImage
|
||||
)
|
||||
.labelStyle(tab.item.labelStyle)
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.eraseToAnyView()
|
||||
}
|
||||
.tag(tab.item.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class TabCoordinator: ObservableObject {
|
||||
|
||||
struct SelectedEvent {
|
||||
let isRoot: Bool
|
||||
let isRepeat: Bool
|
||||
}
|
||||
|
||||
typealias TabData = (
|
||||
item: TabItem,
|
||||
coordinator: NavigationCoordinator,
|
||||
publisher: TabItemSelectedPublisher
|
||||
)
|
||||
|
||||
@Published
|
||||
var selectedTabID: String! = nil {
|
||||
didSet {
|
||||
guard let tab = tabs.first(property: \.item.id, equalTo: selectedTabID) else { return }
|
||||
|
||||
tab.publisher.send(
|
||||
.init(
|
||||
isRoot: tab.coordinator.path.isEmpty,
|
||||
isRepeat: oldValue == selectedTabID
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Published
|
||||
var tabs: [TabData] = []
|
||||
|
||||
init(@ArrayBuilder<TabItem> tabs: () -> [TabItem]) {
|
||||
let tabs = tabs()
|
||||
self.tabs = tabs.map { tab in
|
||||
let coordinator = NavigationCoordinator()
|
||||
let event = TabItemSelectedPublisher()
|
||||
return (tab, coordinator, event)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// TODO: selected icon
|
||||
struct TabItem: Identifiable, Hashable {
|
||||
|
||||
let content: AnyView
|
||||
let id: String
|
||||
let title: String
|
||||
let systemImage: String
|
||||
let labelStyle: any LabelStyle
|
||||
|
||||
init(
|
||||
id: String,
|
||||
title: String,
|
||||
systemImage: String,
|
||||
labelStyle: some LabelStyle = .titleAndIcon,
|
||||
@ViewBuilder content: () -> some View
|
||||
) {
|
||||
self.content = AnyView(content())
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.systemImage = systemImage
|
||||
self.labelStyle = labelStyle
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
extension TabItem {
|
||||
|
||||
static let home = TabItem(
|
||||
id: "home",
|
||||
title: L10n.home,
|
||||
systemImage: "house"
|
||||
) {
|
||||
HomeView()
|
||||
}
|
||||
|
||||
static func library(
|
||||
title: String,
|
||||
systemName: String,
|
||||
filters: ItemFilterCollection
|
||||
) -> TabItem {
|
||||
TabItem(
|
||||
id: "library-\(UUID().uuidString)",
|
||||
title: title,
|
||||
systemImage: systemName
|
||||
) {
|
||||
let viewModel = ItemLibraryViewModel(
|
||||
filters: filters
|
||||
)
|
||||
|
||||
PagingLibraryView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
static let media = TabItem(
|
||||
id: "media",
|
||||
title: L10n.media,
|
||||
systemImage: "rectangle.stack.fill"
|
||||
) {
|
||||
MediaView()
|
||||
}
|
||||
|
||||
static let search = TabItem(
|
||||
id: "search",
|
||||
title: L10n.search,
|
||||
systemImage: "magnifyingglass"
|
||||
) {
|
||||
SearchView()
|
||||
}
|
||||
|
||||
static let settings = TabItem(
|
||||
id: "settings",
|
||||
title: L10n.settings,
|
||||
systemImage: "gearshape",
|
||||
labelStyle: .iconOnly
|
||||
) {
|
||||
SettingsView()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension TabCoordinator {
|
||||
|
||||
typealias TabItemSelectedPublisher = LegacyEventPublisher<TabCoordinator.SelectedEvent>
|
||||
}
|
||||
|
||||
@propertyWrapper
|
||||
struct TabItemSelected: DynamicProperty {
|
||||
|
||||
@Environment(\.tabItemSelected)
|
||||
private var publisher
|
||||
|
||||
var wrappedValue: TabCoordinator.TabItemSelectedPublisher {
|
||||
publisher
|
||||
}
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
|
||||
@Entry
|
||||
var tabItemSelected: TabCoordinator.TabItemSelectedPublisher = .init()
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
|
||||
final class UserSignInCoordinator: NavigationCoordinatable {
|
||||
|
||||
struct SecurityParameters {
|
||||
let pinHint: Binding<String>
|
||||
let accessPolicy: Binding<UserAccessPolicy>
|
||||
}
|
||||
|
||||
let stack = NavigationStack(initial: \UserSignInCoordinator.start)
|
||||
|
||||
@Root
|
||||
var start = makeStart
|
||||
|
||||
@Route(.push)
|
||||
var quickConnect = makeQuickConnect
|
||||
|
||||
#if os(iOS)
|
||||
@Route(.push)
|
||||
var security = makeSecurity
|
||||
#endif
|
||||
|
||||
private let server: ServerState
|
||||
|
||||
init(server: ServerState) {
|
||||
self.server = server
|
||||
}
|
||||
|
||||
func makeQuickConnect(quickConnect: QuickConnect) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
NavigationViewCoordinator {
|
||||
QuickConnectView(quickConnect: quickConnect)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
func makeSecurity(parameters: SecurityParameters) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
NavigationViewCoordinator {
|
||||
UserSignInView.SecurityView(
|
||||
pinHint: parameters.pinHint,
|
||||
accessPolicy: parameters.accessPolicy
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder
|
||||
func makeStart() -> some View {
|
||||
UserSignInView(server: server)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
|
||||
final class UserSignInCoordinator: NavigationCoordinatable {
|
||||
|
||||
struct SecurityParameters {
|
||||
let pinHint: Binding<String>
|
||||
let accessPolicy: Binding<UserAccessPolicy>
|
||||
}
|
||||
|
||||
let stack = NavigationStack(initial: \UserSignInCoordinator.start)
|
||||
|
||||
@Root
|
||||
var start = makeStart
|
||||
|
||||
@Route(.push)
|
||||
var quickConnect = makeQuickConnect
|
||||
|
||||
#if os(iOS)
|
||||
@Route(.push)
|
||||
var security = makeSecurity
|
||||
#endif
|
||||
|
||||
private let server: ServerState
|
||||
|
||||
init(server: ServerState) {
|
||||
self.server = server
|
||||
}
|
||||
|
||||
func makeQuickConnect(quickConnect: QuickConnect) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
NavigationViewCoordinator {
|
||||
QuickConnectView(quickConnect: quickConnect)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
func makeSecurity(parameters: SecurityParameters) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
NavigationViewCoordinator {
|
||||
UserSignInView.SecurityView(
|
||||
pinHint: parameters.pinHint,
|
||||
accessPolicy: parameters.accessPolicy
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder
|
||||
func makeStart() -> some View {
|
||||
UserSignInView(server: server)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import BlurHashKit
|
||||
import SwiftUI
|
||||
|
||||
extension BlurHash {
|
||||
|
||||
var averageLinearColor: Color {
|
||||
let color = averageLinearRGB
|
||||
return Color(
|
||||
red: Double(color.0),
|
||||
green: Double(color.1),
|
||||
blue: Double(color.2)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@propertyWrapper
|
||||
struct BoxedPublished<Value>: DynamicProperty {
|
||||
|
||||
@StateObject
|
||||
var storage: PublishedBox<Value>
|
||||
|
||||
init(wrappedValue: Value) {
|
||||
self._storage = StateObject(wrappedValue: PublishedBox(initialValue: wrappedValue))
|
||||
}
|
||||
|
||||
var wrappedValue: Value {
|
||||
get { storage.value }
|
||||
nonmutating set { storage.value = newValue }
|
||||
}
|
||||
|
||||
var projectedValue: Published<Value>.Publisher {
|
||||
storage.$value
|
||||
}
|
||||
|
||||
var box: PublishedBox<Value> {
|
||||
storage
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
@inlinable
|
||||
func abs(_ d: Duration) -> Duration {
|
||||
d < .zero ? (.zero - d) : d
|
||||
}
|
||||
|
||||
extension Duration {
|
||||
|
||||
/// Represent Jellyfin ticks as a Duration
|
||||
static func ticks(_ ticks: Int) -> Duration {
|
||||
Duration.microseconds(Int64(ticks) / 10)
|
||||
}
|
||||
|
||||
var microseconds: Int64 {
|
||||
(components.attoseconds / 1_000_000_000_000) + components.seconds * 1_000_000
|
||||
}
|
||||
|
||||
var seconds: Double {
|
||||
Double(components.seconds) + Double(components.attoseconds) * 1e-18
|
||||
}
|
||||
|
||||
var ticks: Int {
|
||||
Int(microseconds * 10)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension FocusedValues {
|
||||
|
||||
@Entry
|
||||
var focusedPoster: AnyPoster?
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension AnyView: PlatformView {
|
||||
var iOSView: some View { self }
|
||||
var tvOSView: some View { self }
|
||||
}
|
|
@ -105,6 +105,13 @@ extension BaseItemDto {
|
|||
}
|
||||
|
||||
logger.debug("liveVideoPlayerViewModel matchingMediaSource being returned")
|
||||
logger.debug(" 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!
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
|
||||
extension CountryInfo: Displayable {
|
||||
|
||||
var displayTitle: String {
|
||||
if let twoLetterISORegionName, let name = Locale.current.localizedString(forRegionCode: twoLetterISORegionName) {
|
||||
return name
|
||||
}
|
||||
|
||||
if let threeLetterISORegionName, let name = Locale.current.localizedString(forRegionCode: threeLetterISORegionName) {
|
||||
return name
|
||||
}
|
||||
|
||||
return displayName ?? L10n.unknown
|
||||
}
|
||||
}
|
||||
|
||||
extension CountryInfo {
|
||||
|
||||
static var none: CountryInfo {
|
||||
CountryInfo(
|
||||
displayName: L10n.none
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
|
||||
extension CultureDto: Displayable {
|
||||
|
||||
var displayTitle: String {
|
||||
if let twoLetterISOLanguageName,
|
||||
let name = Locale.current.localizedString(forLanguageCode: twoLetterISOLanguageName)
|
||||
{
|
||||
return name
|
||||
}
|
||||
|
||||
if let threeLetterISOLanguageNames, let displayName = threeLetterISOLanguageNames
|
||||
.compactMap({ Locale.current.localizedString(forLanguageCode: $0) })
|
||||
.first
|
||||
{
|
||||
return displayName
|
||||
}
|
||||
|
||||
return displayName ?? L10n.unknown
|
||||
}
|
||||
|
||||
static var none: CultureDto {
|
||||
CultureDto(
|
||||
displayName: L10n.none
|
||||
)
|
||||
}
|
||||
}
|
|
@ -72,34 +72,91 @@ extension MediaSourceInfo {
|
|||
let playbackURL: URL
|
||||
let playMethod: PlayMethod
|
||||
|
||||
if let transcodingURL {
|
||||
guard let fullTranscodeURL = URL(string: transcodingURL, relativeTo: userSession.server.currentURL)
|
||||
else { throw JellyfinAPIError("Unable to construct transcoded url") }
|
||||
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)")
|
||||
}
|
||||
}
|
||||
|
||||
playbackURL = fullTranscodeURL
|
||||
playMethod = .transcode
|
||||
} else if self.isSupportsDirectPlay ?? false, let path = self.path, let playbackUrl = URL(string: path) {
|
||||
print("🎬 Using live transcoding URL (converted from master): \(playbackURL)")
|
||||
print("🎬 Absolute URL: \(playbackURL.absoluteString)")
|
||||
} else if false, let path = self.path, let pathURL = URL(string: path), pathURL.scheme != nil {
|
||||
// Direct path disabled - fails with AVPlayer connection error
|
||||
playbackURL = pathURL
|
||||
playMethod = .directPlay
|
||||
print("🎬 Using direct path URL (absolute): \(playbackURL)")
|
||||
print("🎬 Absolute URL: \(playbackURL.absoluteString)")
|
||||
} else if false, self.isSupportsDirectPlay ?? false, let path = self.path, let playbackUrl = URL(string: path) {
|
||||
// Relative direct play disabled
|
||||
playbackURL = playbackUrl
|
||||
playMethod = .directPlay
|
||||
print("🎬 Using direct play URL (relative): \(playbackURL)")
|
||||
print("🎬 Absolute URL: \(playbackURL.absoluteString)")
|
||||
} else {
|
||||
let videoStreamParameters = Paths.GetVideoStreamParameters(
|
||||
isStatic: true,
|
||||
tag: item.etag,
|
||||
playSessionID: playSessionID,
|
||||
mediaSourceID: id
|
||||
)
|
||||
// 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 videoStreamRequest = Paths.getVideoStream(
|
||||
itemID: item.id!,
|
||||
parameters: videoStreamParameters
|
||||
)
|
||||
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),
|
||||
]
|
||||
|
||||
guard let fullURL = userSession.client.fullURL(with: videoStreamRequest) else {
|
||||
throw JellyfinAPIError("Unable to construct transcoded url")
|
||||
guard let liveURL = urlComponents.url else {
|
||||
print("🎬 ERROR: Unable to construct live.m3u8 URL")
|
||||
throw JellyfinAPIError("Unable to construct live.m3u8 URL")
|
||||
}
|
||||
playbackURL = fullURL
|
||||
playbackURL = liveURL
|
||||
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 } ?? []
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// TODO: rename as not only used in section footers
|
||||
|
||||
extension LabelStyle where Self == SectionFooterWithImageLabelStyle<AnyShapeStyle> {
|
||||
|
||||
static func sectionFooterWithImage<ImageStyle: ShapeStyle>(
|
||||
imageStyle: ImageStyle
|
||||
) -> SectionFooterWithImageLabelStyle<ImageStyle> {
|
||||
SectionFooterWithImageLabelStyle(imageStyle: imageStyle)
|
||||
}
|
||||
}
|
||||
|
||||
struct SectionFooterWithImageLabelStyle<ImageStyle: ShapeStyle>: LabelStyle {
|
||||
|
||||
let imageStyle: ImageStyle
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
HStack {
|
||||
configuration.icon
|
||||
.foregroundStyle(imageStyle)
|
||||
.fontWeight(.bold)
|
||||
|
||||
configuration.title
|
||||
}
|
||||
}
|
||||
}
|
|
@ -23,7 +23,7 @@ extension DataCache.Swiftfin {
|
|||
|
||||
static let posters: DataCache? = {
|
||||
|
||||
let dataCache = try? DataCache(name: "org.ashik.jellypig/Posters") { name in
|
||||
let dataCache = try? DataCache(name: "se.ashik.jellyflood/Posters") { name in
|
||||
guard let url = name.url else { return nil }
|
||||
return ImagePipeline.cacheKey(for: url)
|
||||
}
|
||||
|
@ -40,7 +40,7 @@ extension DataCache.Swiftfin {
|
|||
return nil
|
||||
}
|
||||
|
||||
let path = root.appendingPathComponent("Caches/org.ashik.jellypig.local", isDirectory: true)
|
||||
let path = root.appendingPathComponent("Caches/se.ashik.jellyflood.local", isDirectory: true)
|
||||
|
||||
let dataCache = try? DataCache(path: path) { name in
|
||||
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct GaugeProgressViewStyle: ProgressViewStyle {
|
||||
|
||||
@Default(.accentColor)
|
||||
private var accentColor
|
||||
|
||||
@State
|
||||
private var contentSize: CGSize = .zero
|
||||
|
||||
private let lineWidthRatio: CGFloat
|
||||
private let systemImage: String?
|
||||
|
||||
init(systemImage: String? = nil) {
|
||||
self.lineWidthRatio = systemImage == nil ? 0.2 : 0.125
|
||||
self.systemImage = systemImage
|
||||
}
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
ZStack {
|
||||
|
||||
if let systemImage {
|
||||
Image(systemName: systemImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(maxWidth: contentSize.width / 2.5, maxHeight: contentSize.height / 2.5)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(6)
|
||||
}
|
||||
|
||||
Circle()
|
||||
.stroke(
|
||||
Color.gray.opacity(0.2),
|
||||
lineWidth: contentSize.width * lineWidthRatio
|
||||
)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: configuration.fractionCompleted ?? 0)
|
||||
.stroke(
|
||||
accentColor,
|
||||
style: StrokeStyle(
|
||||
lineWidth: contentSize.width * lineWidthRatio,
|
||||
lineCap: .round
|
||||
)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
}
|
||||
.animation(.linear(duration: 0.1), value: configuration.fractionCompleted)
|
||||
.trackingSize($contentSize)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct PlaybackProgressViewStyle: ProgressViewStyle {
|
||||
|
||||
enum CornerStyle {
|
||||
case round
|
||||
case square
|
||||
}
|
||||
|
||||
@State
|
||||
private var contentSize: CGSize = .zero
|
||||
|
||||
var secondaryProgress: Double?
|
||||
var cornerStyle: CornerStyle
|
||||
|
||||
@ViewBuilder
|
||||
private func buildCapsule(for progress: Double) -> some View {
|
||||
Rectangle()
|
||||
.cornerRadius(
|
||||
cornerStyle == .round ? contentSize.height / 2 : 0,
|
||||
corners: [.topLeft, .bottomLeft]
|
||||
)
|
||||
.frame(width: contentSize.width * clamp(progress, min: 0, max: 1) + contentSize.height)
|
||||
.offset(x: -contentSize.height)
|
||||
}
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
Capsule()
|
||||
.foregroundStyle(.secondary)
|
||||
.opacity(0.2)
|
||||
.overlay(alignment: .leading) {
|
||||
ZStack(alignment: .leading) {
|
||||
|
||||
if let secondaryProgress,
|
||||
secondaryProgress > 0
|
||||
{
|
||||
buildCapsule(for: secondaryProgress)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
|
||||
if let fractionCompleted = configuration.fractionCompleted {
|
||||
buildCapsule(for: fractionCompleted)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.trackingSize($contentSize)
|
||||
.mask {
|
||||
Capsule()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension ProgressViewStyle where Self == GaugeProgressViewStyle {
|
||||
|
||||
static var gauge: GaugeProgressViewStyle {
|
||||
GaugeProgressViewStyle()
|
||||
}
|
||||
|
||||
static func gauge(systemImage: String) -> GaugeProgressViewStyle {
|
||||
GaugeProgressViewStyle(systemImage: systemImage)
|
||||
}
|
||||
}
|
||||
|
||||
extension ProgressViewStyle where Self == PlaybackProgressViewStyle {
|
||||
|
||||
static var playback: Self { .init(secondaryProgress: nil, cornerStyle: .round) }
|
||||
|
||||
func secondaryProgress(_ progress: Double?) -> Self {
|
||||
copy(self, modifying: \.secondaryProgress, to: progress)
|
||||
}
|
||||
|
||||
var square: Self {
|
||||
copy(self, modifying: \.cornerStyle, to: .square)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Combine
|
||||
|
||||
/// A box for a `Published` value
|
||||
class PublishedBox<Value>: ObservableObject {
|
||||
|
||||
@Published
|
||||
var value: Value
|
||||
|
||||
init(initialValue: Value) {
|
||||
self.value = initialValue
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension Section where Parent == Text, Footer == Text, Content: View {
|
||||
|
||||
init(
|
||||
_ header: String,
|
||||
footer: String,
|
||||
@ViewBuilder content: @escaping () -> Content
|
||||
) {
|
||||
self.init(content: content) {
|
||||
Text(header)
|
||||
} footer: {
|
||||
Text(footer)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,81 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UIImage {
|
||||
|
||||
func getTileImage(
|
||||
columns: Int,
|
||||
rows: Int,
|
||||
index: Int
|
||||
) -> UIImage? {
|
||||
let x = index % columns
|
||||
let y = index / columns
|
||||
|
||||
// Check if the tile index is within the valid range
|
||||
// guard x >= 0, y >= 0, x < columns, y < rows else {
|
||||
// return nil
|
||||
// }
|
||||
|
||||
// Use integer arithmetic for tile dimensions and positions
|
||||
let imageWidth = Int(size.width)
|
||||
let imageHeight = Int(size.height)
|
||||
let tileWidth = imageWidth / columns
|
||||
let tileHeight = imageHeight / rows
|
||||
|
||||
// Calculate the rectangle using integer values
|
||||
let rect = CGRect(
|
||||
x: x * tileWidth,
|
||||
y: y * tileHeight,
|
||||
width: tileWidth,
|
||||
height: tileHeight
|
||||
)
|
||||
|
||||
// This check is now redundant because of the earlier guard statement
|
||||
// guard rect.maxX <= imageWidth && rect.maxY <= imageHeight else {
|
||||
// return nil
|
||||
// }
|
||||
|
||||
if let cgImage = cgImage?.cropping(to: rect) {
|
||||
return UIImage(cgImage: cgImage)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
// guard index >= 0 else {
|
||||
// return nil
|
||||
// }
|
||||
//
|
||||
// let imageWidth = size.width
|
||||
// let imageHeight = size.height
|
||||
//
|
||||
// let tileWidth = imageWidth / CGFloat(columns)
|
||||
// let tileHeight = imageHeight / CGFloat(rows)
|
||||
//
|
||||
// let x = (index % columns)
|
||||
// let y = (index / columns)
|
||||
//
|
||||
// let rect = CGRect(
|
||||
// x: CGFloat(x) * tileWidth,
|
||||
// y: CGFloat(y) * tileHeight,
|
||||
// width: tileWidth,
|
||||
// height: tileHeight
|
||||
// )
|
||||
//
|
||||
// guard rect.maxX <= imageWidth && rect.maxY <= imageHeight else {
|
||||
// return nil
|
||||
// }
|
||||
//
|
||||
// if let cgImage = cgImage?.cropping(to: rect) {
|
||||
// return UIImage(cgImage: cgImage)
|
||||
// }
|
||||
//
|
||||
// return nil
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension UnitPoint {
|
||||
|
||||
var inverted: UnitPoint {
|
||||
UnitPoint(x: 1 - x, y: 1 - y)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Factory
|
||||
import Logging
|
||||
|
||||
extension Logger {
|
||||
|
||||
static func swiftfin() -> Logger {
|
||||
Logger(label: "org.jellyfin.swiftfin")
|
||||
}
|
||||
}
|
||||
|
||||
struct SwiftfinConsoleHandler: LogHandler {
|
||||
|
||||
var logLevel: Logger.Level = .trace
|
||||
var metadata: Logger.Metadata = [:]
|
||||
|
||||
subscript(metadataKey key: String) -> Logger.Metadata.Value? {
|
||||
get {
|
||||
metadata[key]
|
||||
}
|
||||
set(newValue) {
|
||||
metadata[key] = newValue
|
||||
}
|
||||
}
|
||||
|
||||
func log(
|
||||
level: Logger.Level,
|
||||
message: Logger.Message,
|
||||
metadata: Logger.Metadata?,
|
||||
source: String,
|
||||
file: String,
|
||||
function: String,
|
||||
line: UInt
|
||||
) {
|
||||
let line = "[\(level.emoji) \(level.rawValue.capitalized)] \(file.shortFileName)#\(line):\(function) \(message)"
|
||||
let meta = (metadata ?? [:]).merging(self.metadata) { _, new in new }
|
||||
let metadataString = meta.map { "\t- \($0): \($1)" }.joined(separator: "\n")
|
||||
|
||||
print(line)
|
||||
|
||||
if metadataString.isNotEmpty {
|
||||
print(metadataString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Logger.Level {
|
||||
var emoji: String {
|
||||
switch self {
|
||||
case .trace:
|
||||
return "🟣"
|
||||
case .debug:
|
||||
return "🔵"
|
||||
case .info:
|
||||
return "🟢"
|
||||
case .notice:
|
||||
return "🟠"
|
||||
case .warning:
|
||||
return "🟡"
|
||||
case .error:
|
||||
return "🔴"
|
||||
case .critical:
|
||||
return "💥"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import Pulse
|
||||
|
||||
private let redactedMessage = "<Redacted by Swiftfin>"
|
||||
|
||||
extension NetworkLogger {
|
||||
|
||||
static func swiftfin() -> NetworkLogger {
|
||||
var configuration = NetworkLogger.Configuration()
|
||||
|
||||
configuration.willHandleEvent = { event -> LoggerStore.Event? in
|
||||
if case var LoggerStore.Event.networkTaskCompleted(task) = event {
|
||||
guard let url = task.originalRequest.url,
|
||||
let requestBody = task.requestBody
|
||||
else {
|
||||
return event
|
||||
}
|
||||
|
||||
let pathComponents = url.pathComponents
|
||||
|
||||
if pathComponents.last == "AuthenticateByName",
|
||||
var body = try? JSONDecoder().decode(AuthenticateUserByName.self, from: requestBody)
|
||||
{
|
||||
body.pw = redactedMessage
|
||||
task.requestBody = try? JSONEncoder().encode(body)
|
||||
|
||||
return LoggerStore.Event.networkTaskCompleted(task)
|
||||
}
|
||||
|
||||
if pathComponents.last == "Password",
|
||||
var body = try? JSONDecoder().decode(UpdateUserPassword.self, from: requestBody)
|
||||
{
|
||||
body.currentPassword = redactedMessage
|
||||
body.currentPw = redactedMessage
|
||||
body.newPw = redactedMessage
|
||||
body.isResetPassword = nil
|
||||
task.requestBody = try? JSONEncoder().encode(body)
|
||||
|
||||
return LoggerStore.Event.networkTaskCompleted(task)
|
||||
}
|
||||
}
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
return NetworkLogger(configuration: configuration)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import CoreStore
|
||||
import Logging
|
||||
|
||||
struct SwiftfinCorestoreLogger: CoreStoreLogger {
|
||||
|
||||
private let logger = Logger.swiftfin()
|
||||
|
||||
func log(
|
||||
error: CoreStoreError,
|
||||
message: String,
|
||||
fileName: StaticString,
|
||||
lineNumber: Int,
|
||||
functionName: StaticString
|
||||
) {
|
||||
logger.error(
|
||||
"\(message)",
|
||||
metadata: nil,
|
||||
source: "Corestore",
|
||||
file: fileName.description,
|
||||
function: functionName.description,
|
||||
line: UInt(lineNumber)
|
||||
)
|
||||
}
|
||||
|
||||
func log(
|
||||
level: LogLevel,
|
||||
message: String,
|
||||
fileName: StaticString,
|
||||
lineNumber: Int,
|
||||
functionName: StaticString
|
||||
) {
|
||||
logger.log(
|
||||
level: level.asSwiftLog,
|
||||
"\(message)",
|
||||
metadata: nil,
|
||||
source: "Corestore",
|
||||
file: fileName.description,
|
||||
function: functionName.description,
|
||||
line: UInt(lineNumber)
|
||||
)
|
||||
}
|
||||
|
||||
func assert(
|
||||
_ condition: @autoclosure () -> Bool,
|
||||
message: @autoclosure () -> String,
|
||||
fileName: StaticString,
|
||||
lineNumber: Int,
|
||||
functionName: StaticString
|
||||
) {
|
||||
guard !condition() else { return }
|
||||
logger.critical(
|
||||
"\(message())",
|
||||
metadata: nil,
|
||||
source: "Corestore",
|
||||
file: fileName.description,
|
||||
function: functionName.description,
|
||||
line: UInt(lineNumber)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension CoreStore.LogLevel {
|
||||
|
||||
var asSwiftLog: Logger.Level {
|
||||
switch self {
|
||||
case .trace:
|
||||
return .trace
|
||||
case .notice:
|
||||
return .debug
|
||||
case .warning:
|
||||
return .warning
|
||||
case .fatal:
|
||||
return .critical
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
enum ActiveSessionFilter: String, CaseIterable, SystemImageable, Displayable, Storable {
|
||||
|
||||
case all
|
||||
case active
|
||||
case inactive
|
||||
|
||||
var displayTitle: String {
|
||||
switch self {
|
||||
case .all:
|
||||
return L10n.all
|
||||
case .active:
|
||||
return L10n.active
|
||||
case .inactive:
|
||||
return L10n.inactive
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .all:
|
||||
return "line.3.horizontal"
|
||||
case .active:
|
||||
return "play"
|
||||
case .inactive:
|
||||
return "play.slash"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -53,4 +53,20 @@ extension ChannelProgram: Poster {
|
|||
var systemImage: String {
|
||||
channel.systemImage
|
||||
}
|
||||
|
||||
func portraitImageSources(maxWidth: CGFloat? = nil, quality: Int? = nil) -> [ImageSource] {
|
||||
channel.portraitImageSources(maxWidth: maxWidth)
|
||||
}
|
||||
|
||||
func landscapeImageSources(maxWidth: CGFloat? = nil, quality: Int? = nil) -> [ImageSource] {
|
||||
channel.landscapeImageSources(maxWidth: maxWidth)
|
||||
}
|
||||
|
||||
func cinematicImageSources(maxWidth: CGFloat? = nil, quality: Int? = nil) -> [ImageSource] {
|
||||
channel.cinematicImageSources(maxWidth: maxWidth)
|
||||
}
|
||||
|
||||
func squareImageSources(maxWidth: CGFloat? = nil, quality: Int? = nil) -> [ImageSource] {
|
||||
channel.squareImageSources(maxWidth: maxWidth)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class DirectionalPanGestureRecognizer: UIPanGestureRecognizer {
|
||||
|
||||
var direction: Direction
|
||||
|
||||
init(direction: Direction, target: AnyObject, action: Selector) {
|
||||
self.direction = direction
|
||||
super.init(target: target, action: action)
|
||||
}
|
||||
|
||||
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
|
||||
super.touchesMoved(touches, with: event)
|
||||
|
||||
if state == .began {
|
||||
let velocity = velocity(in: view)
|
||||
|
||||
let isUp = velocity.y < 0
|
||||
let isHorizontal = velocity.y.magnitude < velocity.x.magnitude
|
||||
let isVertical = velocity.x.magnitude < velocity.y.magnitude
|
||||
|
||||
switch direction {
|
||||
case .all: ()
|
||||
case .allButDown where isUp || isHorizontal: ()
|
||||
case .horizontal where isHorizontal: ()
|
||||
case .vertical where isVertical: ()
|
||||
case .up where isVertical && velocity.y < 0: ()
|
||||
case .down where isVertical && velocity.y > 0: ()
|
||||
case .left where isHorizontal && velocity.x < 0: ()
|
||||
case .right where isHorizontal && velocity.x > 0: ()
|
||||
default:
|
||||
state = .cancelled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
enum DoubleTouchGestureAction: String, GestureAction {
|
||||
|
||||
case none
|
||||
case aspectFill
|
||||
case gestureLock
|
||||
case pausePlay
|
||||
|
||||
var displayTitle: String {
|
||||
switch self {
|
||||
case .none:
|
||||
return L10n.none
|
||||
case .aspectFill:
|
||||
return L10n.aspectFill
|
||||
case .gestureLock:
|
||||
return L10n.gestureLock
|
||||
case .pausePlay:
|
||||
return L10n.playAndPause
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
// `none` is used since values aren't supported in Defaults
|
||||
// https://github.com/sindresorhus/Defaults/issues/54
|
||||
|
||||
protocol GestureAction: CaseIterable, Displayable, Storable {}
|
|
@ -0,0 +1,22 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
enum LongPressGestureAction: String, GestureAction {
|
||||
|
||||
case none
|
||||
case gestureLock
|
||||
|
||||
var displayTitle: String {
|
||||
switch self {
|
||||
case .none:
|
||||
return L10n.none
|
||||
case .gestureLock:
|
||||
return L10n.gestureLock
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
enum MultiTapGestureAction: String, GestureAction {
|
||||
|
||||
case none
|
||||
case jump
|
||||
|
||||
var displayTitle: String {
|
||||
switch self {
|
||||
case .none:
|
||||
return L10n.none
|
||||
case .jump:
|
||||
return L10n.jump
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
enum PanGestureAction: String, GestureAction {
|
||||
|
||||
case none
|
||||
case brightness
|
||||
case scrub
|
||||
case slowScrub
|
||||
case volume
|
||||
|
||||
var displayTitle: String {
|
||||
switch self {
|
||||
case .none:
|
||||
return L10n.none
|
||||
case .brightness:
|
||||
return L10n.brightness
|
||||
case .scrub:
|
||||
return L10n.scrub
|
||||
case .slowScrub:
|
||||
return L10n.slowScrub
|
||||
case .volume:
|
||||
return L10n.volume
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
enum PinchGestureAction: String, GestureAction {
|
||||
|
||||
case none
|
||||
case aspectFill
|
||||
|
||||
var displayTitle: String {
|
||||
switch self {
|
||||
case .none:
|
||||
return L10n.none
|
||||
case .aspectFill:
|
||||
return L10n.aspectFill
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
enum SwipeGestureAction: String, GestureAction {
|
||||
|
||||
case none
|
||||
case jump
|
||||
|
||||
var displayTitle: String {
|
||||
switch self {
|
||||
case .none:
|
||||
return L10n.none
|
||||
case .jump:
|
||||
return L10n.jump
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct IsStatusBarHiddenKey: PreferenceKey {
|
||||
static var defaultValue: Bool = false
|
||||
|
||||
static func reduce(value: inout Bool, nextValue: () -> Bool) {
|
||||
value = nextValue() || value
|
||||
}
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
@propertyWrapper
|
||||
struct LazyState<Value>: @preconcurrency DynamicProperty {
|
||||
|
||||
final class Box {
|
||||
|
||||
private var value: Value!
|
||||
private let thunk: () -> Value
|
||||
var didThunk = false
|
||||
|
||||
var wrappedValue: Value {
|
||||
value
|
||||
}
|
||||
|
||||
func setup() {
|
||||
value = thunk()
|
||||
didThunk = true
|
||||
}
|
||||
|
||||
init(wrappedValue thunk: @autoclosure @escaping () -> Value) {
|
||||
self.thunk = thunk
|
||||
}
|
||||
}
|
||||
|
||||
@State
|
||||
private var holder: Box
|
||||
|
||||
var wrappedValue: Value {
|
||||
holder.wrappedValue
|
||||
}
|
||||
|
||||
var projectedValue: Binding<Value> {
|
||||
Binding(get: { wrappedValue }, set: { _ in })
|
||||
}
|
||||
|
||||
func update() {
|
||||
guard !holder.didThunk else { return }
|
||||
holder.setup()
|
||||
}
|
||||
|
||||
init(wrappedValue thunk: @autoclosure @escaping () -> Value) {
|
||||
_holder = State(wrappedValue: Box(wrappedValue: thunk()))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
// TODO: conform to `SystemImageable`
|
||||
// - forward to systemImage, backward to secondarySystemImage
|
||||
enum MediaJumpInterval: Storable, RawRepresentable {
|
||||
|
||||
typealias RawValue = Duration
|
||||
|
||||
case five
|
||||
case ten
|
||||
case fifteen
|
||||
case thirty
|
||||
case custom(interval: Duration)
|
||||
|
||||
init?(rawValue: Duration) {
|
||||
switch rawValue {
|
||||
case .seconds(5):
|
||||
self = .five
|
||||
case .seconds(10):
|
||||
self = .ten
|
||||
case .seconds(15):
|
||||
self = .fifteen
|
||||
case .seconds(30):
|
||||
self = .thirty
|
||||
default:
|
||||
self = .custom(interval: rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
var rawValue: Duration {
|
||||
switch self {
|
||||
case .five:
|
||||
.seconds(5)
|
||||
case .ten:
|
||||
.seconds(10)
|
||||
case .fifteen:
|
||||
.seconds(15)
|
||||
case .thirty:
|
||||
.seconds(30)
|
||||
case let .custom(interval):
|
||||
interval
|
||||
}
|
||||
}
|
||||
|
||||
var forwardSystemImage: String {
|
||||
switch self {
|
||||
case .thirty:
|
||||
"goforward.30"
|
||||
case .fifteen:
|
||||
"goforward.15"
|
||||
case .ten:
|
||||
"goforward.10"
|
||||
case .five:
|
||||
"goforward.5"
|
||||
case .custom:
|
||||
"goforward"
|
||||
}
|
||||
}
|
||||
|
||||
var backwardSystemImage: String {
|
||||
switch self {
|
||||
case .thirty:
|
||||
"gobackward.30"
|
||||
case .fifteen:
|
||||
"gobackward.15"
|
||||
case .ten:
|
||||
"gobackward.10"
|
||||
case .five:
|
||||
"gobackward.5"
|
||||
case .custom:
|
||||
"gobackward"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,218 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import Factory
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import Logging
|
||||
|
||||
// TODO: build report of determined values for playback information
|
||||
// - transcode, video stream, path
|
||||
|
||||
extension MediaPlayerItem {
|
||||
|
||||
/// The main `MediaPlayerItem` builder for normal online usage.
|
||||
static func build(
|
||||
for initialItem: BaseItemDto,
|
||||
mediaSource _initialMediaSource: MediaSourceInfo? = nil,
|
||||
videoPlayerType: VideoPlayerType = Defaults[.VideoPlayer.videoPlayerType],
|
||||
requestedBitrate: PlaybackBitrate = Defaults[.VideoPlayer.Playback.appMaximumBitrate],
|
||||
compatibilityMode: PlaybackCompatibility = Defaults[.VideoPlayer.Playback.compatibilityMode],
|
||||
modifyItem: ((inout BaseItemDto) -> Void)? = nil
|
||||
) async throws -> MediaPlayerItem {
|
||||
|
||||
let logger = Logger.swiftfin()
|
||||
|
||||
guard let itemID = initialItem.id else {
|
||||
logger.critical("No item ID!")
|
||||
throw JellyfinAPIError(L10n.unknownError)
|
||||
}
|
||||
|
||||
guard let userSession = Container.shared.currentUserSession() else {
|
||||
logger.critical("No user session!")
|
||||
throw JellyfinAPIError(L10n.unknownError)
|
||||
}
|
||||
|
||||
var item = try await initialItem.getFullItem(userSession: userSession)
|
||||
|
||||
if let modifyItem {
|
||||
modifyItem(&item)
|
||||
}
|
||||
|
||||
guard let initialMediaSource = {
|
||||
if let _initialMediaSource {
|
||||
return _initialMediaSource
|
||||
}
|
||||
|
||||
if let first = item.mediaSources?.first {
|
||||
logger.trace("Using first media source for item \(itemID)")
|
||||
return first
|
||||
}
|
||||
|
||||
return nil
|
||||
}() else {
|
||||
logger.error("No media sources for item \(itemID)!")
|
||||
throw JellyfinAPIError(L10n.unknownError)
|
||||
}
|
||||
|
||||
let maxBitrate = try await requestedBitrate.getMaxBitrate()
|
||||
|
||||
let deviceProfile = DeviceProfile.build(
|
||||
for: videoPlayerType,
|
||||
compatibilityMode: compatibilityMode,
|
||||
maxBitrate: maxBitrate
|
||||
)
|
||||
|
||||
var playbackInfo = PlaybackInfoDto()
|
||||
playbackInfo.isAutoOpenLiveStream = true
|
||||
playbackInfo.deviceProfile = deviceProfile
|
||||
playbackInfo.liveStreamID = initialMediaSource.liveStreamID
|
||||
playbackInfo.maxStreamingBitrate = maxBitrate
|
||||
playbackInfo.userID = userSession.user.id
|
||||
|
||||
let request = Paths.getPostedPlaybackInfo(
|
||||
itemID: itemID,
|
||||
playbackInfo
|
||||
)
|
||||
|
||||
let response = try await userSession.client.send(request)
|
||||
|
||||
let mediaSource: MediaSourceInfo? = {
|
||||
|
||||
guard let mediaSources = response.value.mediaSources else { return nil }
|
||||
|
||||
if let matchingTag = mediaSources.first(where: { $0.eTag == initialMediaSource.eTag }) {
|
||||
return matchingTag
|
||||
}
|
||||
|
||||
for source in mediaSources {
|
||||
if let openToken = source.openToken,
|
||||
let id = source.id,
|
||||
openToken.contains(id)
|
||||
{
|
||||
return source
|
||||
}
|
||||
}
|
||||
|
||||
logger.warning("Unable to find matching media source, defaulting to first media source")
|
||||
|
||||
return mediaSources.first
|
||||
}()
|
||||
|
||||
guard let mediaSource else {
|
||||
throw JellyfinAPIError("Unable to find media source for item")
|
||||
}
|
||||
|
||||
guard let playSessionID = response.value.playSessionID else {
|
||||
throw JellyfinAPIError("No associated play session ID")
|
||||
}
|
||||
|
||||
let playbackURL = try Self.streamURL(
|
||||
item: item,
|
||||
mediaSource: mediaSource,
|
||||
playSessionID: playSessionID,
|
||||
userSession: userSession,
|
||||
logger: logger
|
||||
)
|
||||
|
||||
let previewImageProvider: (any PreviewImageProvider)? = {
|
||||
let previewImageScrubbingSetting = StoredValues[.User.previewImageScrubbing]
|
||||
lazy var chapterPreviewImageProvider: ChapterPreviewImageProvider? = {
|
||||
if let chapters = item.fullChapterInfo, chapters.isNotEmpty {
|
||||
return ChapterPreviewImageProvider(chapters: chapters)
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
if case let PreviewImageScrubbingOption.trickplay(fallbackToChapters: fallbackToChapters) = previewImageScrubbingSetting {
|
||||
if let mediaSourceID = mediaSource.id,
|
||||
let trickplayInfo = item.trickplay?[mediaSourceID]?.first
|
||||
{
|
||||
return TrickplayPreviewImageProvider(
|
||||
info: trickplayInfo.value,
|
||||
itemID: itemID,
|
||||
mediaSourceID: mediaSourceID,
|
||||
runtime: item.runtime ?? .zero
|
||||
)
|
||||
}
|
||||
|
||||
if fallbackToChapters {
|
||||
return chapterPreviewImageProvider
|
||||
}
|
||||
} else if previewImageScrubbingSetting == .chapters {
|
||||
return chapterPreviewImageProvider
|
||||
}
|
||||
|
||||
return nil
|
||||
}()
|
||||
|
||||
return .init(
|
||||
baseItem: item,
|
||||
mediaSource: mediaSource,
|
||||
playSessionID: playSessionID,
|
||||
url: playbackURL,
|
||||
requestedBitrate: requestedBitrate,
|
||||
previewImageProvider: previewImageProvider,
|
||||
thumbnailProvider: item.getNowPlayingImage
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: audio type stream
|
||||
// TODO: build live tv stream from Paths.getLiveHlsStream?
|
||||
private static func streamURL(
|
||||
item: BaseItemDto,
|
||||
mediaSource: MediaSourceInfo,
|
||||
playSessionID: String,
|
||||
userSession: UserSession,
|
||||
logger: Logger
|
||||
) throws -> URL {
|
||||
|
||||
guard let itemID = item.id else {
|
||||
throw JellyfinAPIError("No item ID while building online media player item!")
|
||||
}
|
||||
|
||||
if let transcodingURL = mediaSource.transcodingURL {
|
||||
logger.trace("Using transcoding URL for item \(itemID)")
|
||||
|
||||
guard let fullTranscodeURL = userSession.client.fullURL(with: transcodingURL)
|
||||
else { throw JellyfinAPIError("Unable to make transcode URL") }
|
||||
return fullTranscodeURL
|
||||
}
|
||||
|
||||
if item.mediaType == .video, !item.isLiveStream {
|
||||
|
||||
logger.trace("Making video stream URL for item \(itemID)")
|
||||
|
||||
let videoStreamParameters = Paths.GetVideoStreamParameters(
|
||||
isStatic: true,
|
||||
tag: item.etag,
|
||||
playSessionID: playSessionID,
|
||||
mediaSourceID: itemID
|
||||
)
|
||||
|
||||
let videoStreamRequest = Paths.getVideoStream(
|
||||
itemID: itemID,
|
||||
parameters: videoStreamParameters
|
||||
)
|
||||
|
||||
guard let videoStreamURL = userSession.client.fullURL(with: videoStreamRequest)
|
||||
else { throw JellyfinAPIError("Unable to make video stream URL") }
|
||||
|
||||
return videoStreamURL
|
||||
}
|
||||
|
||||
logger.trace("Using media source path for item \(itemID)")
|
||||
|
||||
guard let path = mediaSource.path, let streamURL = URL(
|
||||
string: path
|
||||
) else { throw JellyfinAPIError("Unable to make stream URL") }
|
||||
|
||||
return streamURL
|
||||
}
|
||||
}
|
|
@ -0,0 +1,102 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
// TODO: get preview image for current manager seconds?
|
||||
// - would make scrubbing image possibly ready before scrubbing
|
||||
// TODO: fix leaks
|
||||
// - made from publishers of observers not being cancelled
|
||||
|
||||
@MainActor
|
||||
class MediaPlayerItem: ViewModel, MediaPlayerObserver {
|
||||
|
||||
typealias ThumbnailProvider = () async -> UIImage?
|
||||
|
||||
@Published
|
||||
var selectedAudioStreamIndex: Int? = nil {
|
||||
didSet {
|
||||
if let proxy = manager?.proxy as? any VideoMediaPlayerProxy {
|
||||
proxy.setAudioStream(.init(index: selectedAudioStreamIndex))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published
|
||||
var selectedSubtitleStreamIndex: Int? = nil {
|
||||
didSet {
|
||||
if let proxy = manager?.proxy as? any VideoMediaPlayerProxy {
|
||||
proxy.setSubtitleStream(.init(index: selectedSubtitleStreamIndex))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
weak var manager: MediaPlayerManager? {
|
||||
didSet {
|
||||
for var o in observers {
|
||||
o.manager = manager
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var observers: [any MediaPlayerObserver] = []
|
||||
|
||||
let baseItem: BaseItemDto
|
||||
let mediaSource: MediaSourceInfo
|
||||
let playSessionID: String
|
||||
let previewImageProvider: (any PreviewImageProvider)?
|
||||
let thumbnailProvider: ThumbnailProvider?
|
||||
let url: URL
|
||||
|
||||
let audioStreams: [MediaStream]
|
||||
let subtitleStreams: [MediaStream]
|
||||
let videoStreams: [MediaStream]
|
||||
|
||||
let requestedBitrate: PlaybackBitrate
|
||||
|
||||
// MARK: init
|
||||
|
||||
init(
|
||||
baseItem: BaseItemDto,
|
||||
mediaSource: MediaSourceInfo,
|
||||
playSessionID: String,
|
||||
url: URL,
|
||||
requestedBitrate: PlaybackBitrate = .max,
|
||||
previewImageProvider: (any PreviewImageProvider)? = nil,
|
||||
thumbnailProvider: ThumbnailProvider? = nil
|
||||
) {
|
||||
self.baseItem = baseItem
|
||||
self.mediaSource = mediaSource
|
||||
self.playSessionID = playSessionID
|
||||
self.requestedBitrate = requestedBitrate
|
||||
self.previewImageProvider = previewImageProvider
|
||||
self.thumbnailProvider = thumbnailProvider
|
||||
self.url = url
|
||||
|
||||
let adjustedMediaStreams = mediaSource.mediaStreams?.adjustedTrackIndexes(
|
||||
for: mediaSource.transcodingURL == nil ? .directPlay : .transcode,
|
||||
selectedAudioStreamIndex: mediaSource.defaultAudioStreamIndex ?? 0
|
||||
)
|
||||
|
||||
let audioStreams = adjustedMediaStreams?.filter { $0.type == .audio } ?? []
|
||||
let subtitleStreams = adjustedMediaStreams?.filter { $0.type == .subtitle } ?? []
|
||||
let videoStreams = adjustedMediaStreams?.filter { $0.type == .video } ?? []
|
||||
|
||||
self.audioStreams = audioStreams
|
||||
self.subtitleStreams = subtitleStreams
|
||||
self.videoStreams = videoStreams
|
||||
|
||||
super.init()
|
||||
|
||||
selectedAudioStreamIndex = mediaSource.defaultAudioStreamIndex ?? -1
|
||||
selectedSubtitleStreamIndex = mediaSource.defaultSubtitleStreamIndex ?? -1
|
||||
|
||||
observers.append(MediaProgressObserver(item: self))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,240 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Combine
|
||||
import Defaults
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
// TODO: After NativeVideoPlayer is removed, can move bindings and
|
||||
// observers to AVPlayerView, like the VLC delegate
|
||||
// - wouldn't need to have MediaPlayerProxy: MediaPlayerObserver
|
||||
// TODO: report playback information, see VLCUI.PlaybackInformation (dropped frames, etc.)
|
||||
// TODO: report buffering state
|
||||
// TODO: have set seconds with completion handler
|
||||
|
||||
@MainActor
|
||||
class AVMediaPlayerProxy: VideoMediaPlayerProxy {
|
||||
|
||||
let isBuffering: PublishedBox<Bool> = .init(initialValue: false)
|
||||
var isScrubbing: Binding<Bool> = .constant(false)
|
||||
var scrubbedSeconds: Binding<Duration> = .constant(.zero)
|
||||
var videoSize: PublishedBox<CGSize> = .init(initialValue: .zero)
|
||||
|
||||
let avPlayerLayer: AVPlayerLayer
|
||||
let player: AVPlayer
|
||||
|
||||
// private var rateObserver: NSKeyValueObservation!
|
||||
private var statusObserver: NSKeyValueObservation!
|
||||
private var timeControlStatusObserver: NSKeyValueObservation!
|
||||
private var timeObserver: Any!
|
||||
private var managerItemObserver: AnyCancellable?
|
||||
private var managerStateObserver: AnyCancellable?
|
||||
|
||||
weak var manager: MediaPlayerManager? {
|
||||
didSet {
|
||||
if let manager {
|
||||
managerItemObserver = manager.$playbackItem
|
||||
.sink { playbackItem in
|
||||
if let playbackItem {
|
||||
self.playNew(item: playbackItem)
|
||||
}
|
||||
}
|
||||
|
||||
managerStateObserver = manager.$state
|
||||
.sink { state in
|
||||
switch state {
|
||||
case .stopped:
|
||||
self.playbackStopped()
|
||||
default: break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
managerItemObserver?.cancel()
|
||||
managerStateObserver?.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
self.player = AVPlayer()
|
||||
self.avPlayerLayer = AVPlayerLayer(player: player)
|
||||
|
||||
timeObserver = player.addPeriodicTimeObserver(
|
||||
forInterval: CMTime(seconds: 1, preferredTimescale: 1000),
|
||||
queue: .main
|
||||
) { newTime in
|
||||
let newSeconds = Duration.seconds(newTime.seconds)
|
||||
|
||||
if !self.isScrubbing.wrappedValue {
|
||||
self.scrubbedSeconds.wrappedValue = newSeconds
|
||||
}
|
||||
|
||||
self.manager?.seconds = newSeconds
|
||||
}
|
||||
}
|
||||
|
||||
func play() {
|
||||
player.play()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
player.pause()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
player.pause()
|
||||
}
|
||||
|
||||
func jumpForward(_ seconds: Duration) {
|
||||
let currentTime = player.currentTime()
|
||||
let newTime = currentTime + CMTime(seconds: seconds.seconds, preferredTimescale: 1)
|
||||
player.seek(to: newTime, toleranceBefore: .zero, toleranceAfter: .zero)
|
||||
}
|
||||
|
||||
func jumpBackward(_ seconds: Duration) {
|
||||
let currentTime = player.currentTime()
|
||||
let newTime = max(.zero, currentTime - CMTime(seconds: seconds.seconds, preferredTimescale: 1))
|
||||
player.seek(to: newTime, toleranceBefore: .zero, toleranceAfter: .zero)
|
||||
}
|
||||
|
||||
func setSeconds(_ seconds: Duration) {
|
||||
let time = CMTime(seconds: seconds.seconds, preferredTimescale: 1)
|
||||
player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero)
|
||||
}
|
||||
|
||||
// TODO: complete
|
||||
func setRate(_ rate: Float) {}
|
||||
func setAudioStream(_ stream: MediaStream) {}
|
||||
func setSubtitleStream(_ stream: MediaStream) {}
|
||||
|
||||
func setAspectFill(_ aspectFill: Bool) {
|
||||
avPlayerLayer.videoGravity = aspectFill ? .resizeAspectFill : .resizeAspect
|
||||
}
|
||||
|
||||
var videoPlayerBody: some View {
|
||||
AVPlayerView()
|
||||
.environmentObject(self)
|
||||
}
|
||||
}
|
||||
|
||||
extension AVMediaPlayerProxy {
|
||||
|
||||
private func playbackStopped() {
|
||||
player.pause()
|
||||
guard let timeObserver else { return }
|
||||
player.removeTimeObserver(timeObserver)
|
||||
// rateObserver.invalidate()
|
||||
statusObserver.invalidate()
|
||||
timeControlStatusObserver.invalidate()
|
||||
}
|
||||
|
||||
private func playNew(item: MediaPlayerItem) {
|
||||
let baseItem = item.baseItem
|
||||
|
||||
let newAVPlayerItem = AVPlayerItem(url: item.url)
|
||||
newAVPlayerItem.externalMetadata = item.baseItem.avMetadata
|
||||
|
||||
player.replaceCurrentItem(with: newAVPlayerItem)
|
||||
|
||||
// TODO: protect against paused
|
||||
// rateObserver = player.observe(\.rate, options: [.new, .initial]) { _, value in
|
||||
// DispatchQueue.main.async {
|
||||
// self.manager?.set(rate: value.newValue ?? 1.0)
|
||||
// }
|
||||
// }
|
||||
|
||||
timeControlStatusObserver = player.observe(\.timeControlStatus, options: [.new, .initial]) { player, _ in
|
||||
let timeControlStatus = player.timeControlStatus
|
||||
|
||||
DispatchQueue.main.async {
|
||||
switch timeControlStatus {
|
||||
case .paused:
|
||||
self.manager?.setPlaybackRequestStatus(status: .paused)
|
||||
case .waitingToPlayAtSpecifiedRate: ()
|
||||
// TODO: buffering
|
||||
case .playing:
|
||||
self.manager?.setPlaybackRequestStatus(status: .playing)
|
||||
@unknown default: ()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: proper handling of none/unknown states
|
||||
statusObserver = player.observe(\.currentItem?.status, options: [.new, .initial]) { _, value in
|
||||
guard let newValue = value.newValue else { return }
|
||||
switch newValue {
|
||||
case .failed:
|
||||
if let error = self.player.error {
|
||||
DispatchQueue.main.async {
|
||||
self.manager?.error(JellyfinAPIError("AVPlayer error: \(error.localizedDescription)"))
|
||||
}
|
||||
}
|
||||
case .none, .readyToPlay, .unknown:
|
||||
let startSeconds = max(.zero, (baseItem.startSeconds ?? .zero) - Duration.seconds(Defaults[.VideoPlayer.resumeOffset]))
|
||||
|
||||
self.player.seek(
|
||||
to: CMTimeMake(
|
||||
value: startSeconds.components.seconds,
|
||||
timescale: 1
|
||||
),
|
||||
toleranceBefore: .zero,
|
||||
toleranceAfter: .zero,
|
||||
completionHandler: { _ in
|
||||
self.play()
|
||||
}
|
||||
)
|
||||
@unknown default: ()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AVPlayerView
|
||||
|
||||
extension AVMediaPlayerProxy {
|
||||
|
||||
struct AVPlayerView: UIViewRepresentable {
|
||||
|
||||
@EnvironmentObject
|
||||
private var proxy: AVMediaPlayerProxy
|
||||
@EnvironmentObject
|
||||
private var scrubbedSeconds: PublishedBox<Duration>
|
||||
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
// proxy.isScrubbing = context.environment.isScrubbing
|
||||
// proxy.scrubbedSeconds = $scrubbedSeconds.value
|
||||
UIAVPlayerView(proxy: proxy)
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIView, context: Context) {}
|
||||
}
|
||||
|
||||
private class UIAVPlayerView: UIView {
|
||||
|
||||
let proxy: AVMediaPlayerProxy
|
||||
|
||||
init(proxy: AVMediaPlayerProxy) {
|
||||
self.proxy = proxy
|
||||
super.init(frame: .zero)
|
||||
layer.addSublayer(proxy.avPlayerLayer)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
proxy.avPlayerLayer.frame = bounds
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,230 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
import VLCUI
|
||||
|
||||
class VLCMediaPlayerProxy: VideoMediaPlayerProxy,
|
||||
MediaPlayerOffsetConfigurable,
|
||||
MediaPlayerSubtitleConfigurable
|
||||
{
|
||||
|
||||
let isBuffering: PublishedBox<Bool> = .init(initialValue: false)
|
||||
let videoSize: PublishedBox<CGSize> = .init(initialValue: .zero)
|
||||
let vlcUIProxy: VLCVideoPlayer.Proxy = .init()
|
||||
|
||||
weak var manager: MediaPlayerManager? {
|
||||
didSet {
|
||||
for var o in observers {
|
||||
o.manager = manager
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var observers: [any MediaPlayerObserver] = [
|
||||
NowPlayableObserver(),
|
||||
]
|
||||
|
||||
func play() {
|
||||
vlcUIProxy.play()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
vlcUIProxy.pause()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
vlcUIProxy.stop()
|
||||
}
|
||||
|
||||
func jumpForward(_ seconds: Duration) {
|
||||
vlcUIProxy.jumpForward(seconds)
|
||||
}
|
||||
|
||||
func jumpBackward(_ seconds: Duration) {
|
||||
vlcUIProxy.jumpBackward(seconds)
|
||||
}
|
||||
|
||||
func setRate(_ rate: Float) {
|
||||
vlcUIProxy.setRate(.absolute(rate))
|
||||
}
|
||||
|
||||
func setSeconds(_ seconds: Duration) {
|
||||
vlcUIProxy.setSeconds(seconds)
|
||||
}
|
||||
|
||||
func setAudioStream(_ stream: MediaStream) {
|
||||
vlcUIProxy.setAudioTrack(.absolute(stream.index ?? -1))
|
||||
}
|
||||
|
||||
func setSubtitleStream(_ stream: MediaStream) {
|
||||
vlcUIProxy.setSubtitleTrack(.absolute(stream.index ?? -1))
|
||||
}
|
||||
|
||||
func setAspectFill(_ aspectFill: Bool) {
|
||||
vlcUIProxy.aspectFill(aspectFill ? 1 : 0)
|
||||
}
|
||||
|
||||
func setAudioOffset(_ seconds: Duration) {
|
||||
vlcUIProxy.setAudioDelay(seconds)
|
||||
}
|
||||
|
||||
func setSubtitleOffset(_ seconds: Duration) {
|
||||
vlcUIProxy.setSubtitleDelay(seconds)
|
||||
}
|
||||
|
||||
func setSubtitleColor(_ color: Color) {
|
||||
vlcUIProxy.setSubtitleColor(.absolute(color.uiColor))
|
||||
}
|
||||
|
||||
func setSubtitleFontName(_ fontName: String) {
|
||||
vlcUIProxy.setSubtitleFont(fontName)
|
||||
}
|
||||
|
||||
func setSubtitleFontSize(_ fontSize: Int) {
|
||||
vlcUIProxy.setSubtitleSize(.absolute(fontSize))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var videoPlayerBody: some View {
|
||||
VLCPlayerView()
|
||||
.environmentObject(vlcUIProxy)
|
||||
}
|
||||
}
|
||||
|
||||
extension VLCMediaPlayerProxy {
|
||||
|
||||
struct VLCPlayerView: View {
|
||||
|
||||
@Default(.VideoPlayer.Subtitle.subtitleColor)
|
||||
private var subtitleColor
|
||||
@Default(.VideoPlayer.Subtitle.subtitleFontName)
|
||||
private var subtitleFontName
|
||||
@Default(.VideoPlayer.Subtitle.subtitleSize)
|
||||
private var subtitleSize
|
||||
|
||||
@EnvironmentObject
|
||||
private var containerState: VideoPlayerContainerState
|
||||
@EnvironmentObject
|
||||
private var manager: MediaPlayerManager
|
||||
@EnvironmentObject
|
||||
private var proxy: VLCVideoPlayer.Proxy
|
||||
|
||||
private var isScrubbing: Bool {
|
||||
containerState.isScrubbing
|
||||
}
|
||||
|
||||
private func vlcConfiguration(for item: MediaPlayerItem) -> VLCVideoPlayer.Configuration {
|
||||
let baseItem = item.baseItem
|
||||
let mediaSource = item.mediaSource
|
||||
|
||||
var configuration = VLCVideoPlayer.Configuration(url: item.url)
|
||||
configuration.autoPlay = true
|
||||
|
||||
let startSeconds = max(.zero, (baseItem.startSeconds ?? .zero) - Duration.seconds(Defaults[.VideoPlayer.resumeOffset]))
|
||||
|
||||
if !baseItem.isLiveStream {
|
||||
configuration.startSeconds = startSeconds
|
||||
configuration.audioIndex = .absolute(mediaSource.defaultAudioStreamIndex ?? -1)
|
||||
configuration.subtitleIndex = .absolute(mediaSource.defaultSubtitleStreamIndex ?? -1)
|
||||
}
|
||||
|
||||
configuration.subtitleSize = .absolute(25 - Defaults[.VideoPlayer.Subtitle.subtitleSize])
|
||||
configuration.subtitleColor = .absolute(Defaults[.VideoPlayer.Subtitle.subtitleColor].uiColor)
|
||||
|
||||
if let font = UIFont(name: Defaults[.VideoPlayer.Subtitle.subtitleFontName], size: 1) {
|
||||
configuration.subtitleFont = .absolute(font)
|
||||
}
|
||||
|
||||
configuration.playbackChildren = item.subtitleStreams
|
||||
.filter { $0.deliveryMethod == .external }
|
||||
.compactMap(\.asVLCPlaybackChild)
|
||||
|
||||
return configuration
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let playbackItem = manager.playbackItem, manager.state != .stopped {
|
||||
VLCVideoPlayer(configuration: vlcConfiguration(for: playbackItem))
|
||||
.proxy(proxy)
|
||||
.onSecondsUpdated { newSeconds, info in
|
||||
if !isScrubbing {
|
||||
containerState.scrubbedSeconds.value = newSeconds
|
||||
}
|
||||
|
||||
manager.seconds = newSeconds
|
||||
|
||||
if let proxy = manager.proxy as? any VideoMediaPlayerProxy {
|
||||
proxy.videoSize.value = info.videoSize
|
||||
}
|
||||
}
|
||||
.onStateUpdated { state, info in
|
||||
manager.logger.trace("VLC state updated: \(state)")
|
||||
|
||||
switch state {
|
||||
case .buffering,
|
||||
.esAdded,
|
||||
.opening:
|
||||
// TODO: figure out when to properly set to false
|
||||
manager.proxy?.isBuffering.value = true
|
||||
case .ended:
|
||||
// Live streams will send stopped/ended events
|
||||
guard !playbackItem.baseItem.isLiveStream else { return }
|
||||
manager.proxy?.isBuffering.value = false
|
||||
manager.ended()
|
||||
case .stopped: ()
|
||||
// Stopped is ignored as the `MediaPlayerManager`
|
||||
// should instead call this to be stopped, rather
|
||||
// than react to the event.
|
||||
case .error:
|
||||
manager.proxy?.isBuffering.value = false
|
||||
manager.error(JellyfinAPIError("VLC player is unable to perform playback"))
|
||||
case .playing:
|
||||
manager.proxy?.isBuffering.value = false
|
||||
manager.setPlaybackRequestStatus(status: .playing)
|
||||
case .paused:
|
||||
manager.setPlaybackRequestStatus(status: .paused)
|
||||
}
|
||||
|
||||
if let proxy = manager.proxy as? any VideoMediaPlayerProxy {
|
||||
proxy.videoSize.value = info.videoSize
|
||||
}
|
||||
}
|
||||
.onReceive(manager.$playbackItem) { playbackItem in
|
||||
guard let playbackItem else { return }
|
||||
proxy.playNewMedia(vlcConfiguration(for: playbackItem))
|
||||
}
|
||||
.backport
|
||||
.onChange(of: manager.rate) { _, newValue in
|
||||
proxy.setRate(.absolute(newValue))
|
||||
}
|
||||
.backport
|
||||
.onChange(of: subtitleColor) { _, newValue in
|
||||
if let proxy = proxy as? MediaPlayerSubtitleConfigurable {
|
||||
proxy.setSubtitleColor(newValue)
|
||||
}
|
||||
}
|
||||
.backport
|
||||
.onChange(of: subtitleFontName) { _, newValue in
|
||||
if let proxy = proxy as? MediaPlayerSubtitleConfigurable {
|
||||
proxy.setSubtitleFontName(newValue)
|
||||
}
|
||||
}
|
||||
.backport
|
||||
.onChange(of: subtitleSize) { _, newValue in
|
||||
if let proxy = proxy as? MediaPlayerSubtitleConfigurable {
|
||||
proxy.setSubtitleFontSize(25 - newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
// TODO: feature implementations
|
||||
// - PiP
|
||||
// TODO: Chromecast proxy
|
||||
|
||||
/// The proxy for top-down communication to an
|
||||
/// underlying media player
|
||||
protocol MediaPlayerProxy: ObservableObject, MediaPlayerObserver {
|
||||
|
||||
var isBuffering: PublishedBox<Bool> { get }
|
||||
|
||||
func play()
|
||||
func pause()
|
||||
func stop()
|
||||
|
||||
func jumpForward(_ seconds: Duration)
|
||||
func jumpBackward(_ seconds: Duration)
|
||||
func setRate(_ rate: Float)
|
||||
func setSeconds(_ seconds: Duration)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
protocol VideoMediaPlayerProxy: MediaPlayerProxy {
|
||||
|
||||
associatedtype VideoPlayerBody: View
|
||||
|
||||
var videoSize: PublishedBox<CGSize> { get }
|
||||
|
||||
// TODO: remove when container view handles aspect fill
|
||||
func setAspectFill(_ aspectFill: Bool)
|
||||
func setAudioStream(_ stream: MediaStream)
|
||||
func setSubtitleStream(_ stream: MediaStream)
|
||||
|
||||
@ViewBuilder
|
||||
@MainActor
|
||||
var videoPlayerBody: Self.VideoPlayerBody { get }
|
||||
}
|
||||
|
||||
protocol MediaPlayerOffsetConfigurable {
|
||||
func setAudioOffset(_ seconds: Duration)
|
||||
func setSubtitleOffset(_ seconds: Duration)
|
||||
}
|
||||
|
||||
protocol MediaPlayerSubtitleConfigurable {
|
||||
func setSubtitleColor(_ color: Color)
|
||||
func setSubtitleFontName(_ fontName: String)
|
||||
func setSubtitleFontSize(_ fontSize: Int)
|
||||
}
|
|
@ -0,0 +1,115 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MediaPlayer
|
||||
|
||||
enum NowPlayableCommand: CaseIterable {
|
||||
|
||||
// Play/Pause
|
||||
case pause
|
||||
case play
|
||||
case stop
|
||||
case togglePausePlay
|
||||
|
||||
// Track
|
||||
case nextTrack
|
||||
case previousTrack
|
||||
case changeRepeatMode
|
||||
case changeShuffleMode
|
||||
|
||||
// Seeking/Rate
|
||||
case changePlaybackRate
|
||||
case seekBackward
|
||||
case seekForward
|
||||
case skipBackward
|
||||
case skipForward
|
||||
case changePlaybackPosition
|
||||
|
||||
// Like/Dislike
|
||||
case rating
|
||||
case like
|
||||
case dislike
|
||||
|
||||
// Bookmark
|
||||
case bookmark
|
||||
|
||||
// Languages
|
||||
case enableLanguageOption
|
||||
case disableLanguageOption
|
||||
|
||||
var remoteCommand: MPRemoteCommand {
|
||||
let remoteCommandCenter = MPRemoteCommandCenter.shared()
|
||||
|
||||
switch self {
|
||||
case .pause:
|
||||
return remoteCommandCenter.pauseCommand
|
||||
case .play:
|
||||
return remoteCommandCenter.playCommand
|
||||
case .stop:
|
||||
return remoteCommandCenter.stopCommand
|
||||
case .togglePausePlay:
|
||||
return remoteCommandCenter.togglePlayPauseCommand
|
||||
case .nextTrack:
|
||||
return remoteCommandCenter.nextTrackCommand
|
||||
case .previousTrack:
|
||||
return remoteCommandCenter.previousTrackCommand
|
||||
case .changeRepeatMode:
|
||||
return remoteCommandCenter.changeRepeatModeCommand
|
||||
case .changeShuffleMode:
|
||||
return remoteCommandCenter.changeShuffleModeCommand
|
||||
case .changePlaybackRate:
|
||||
return remoteCommandCenter.changePlaybackRateCommand
|
||||
case .seekBackward:
|
||||
return remoteCommandCenter.seekBackwardCommand
|
||||
case .seekForward:
|
||||
return remoteCommandCenter.seekForwardCommand
|
||||
case .skipBackward:
|
||||
return remoteCommandCenter.skipBackwardCommand
|
||||
case .skipForward:
|
||||
return remoteCommandCenter.skipForwardCommand
|
||||
case .changePlaybackPosition:
|
||||
return remoteCommandCenter.changePlaybackPositionCommand
|
||||
case .rating:
|
||||
return remoteCommandCenter.ratingCommand
|
||||
case .like:
|
||||
return remoteCommandCenter.likeCommand
|
||||
case .dislike:
|
||||
return remoteCommandCenter.dislikeCommand
|
||||
case .bookmark:
|
||||
return remoteCommandCenter.bookmarkCommand
|
||||
case .enableLanguageOption:
|
||||
return remoteCommandCenter.enableLanguageOptionCommand
|
||||
case .disableLanguageOption:
|
||||
return remoteCommandCenter.disableLanguageOptionCommand
|
||||
}
|
||||
}
|
||||
|
||||
func removeHandler() {
|
||||
remoteCommand.removeTarget(nil)
|
||||
}
|
||||
|
||||
func addHandler(_ handler: @escaping (NowPlayableCommand, MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus) {
|
||||
|
||||
remoteCommand.removeTarget(nil)
|
||||
|
||||
switch self {
|
||||
case .skipBackward:
|
||||
MPRemoteCommandCenter.shared().skipBackwardCommand.preferredIntervals = [15.0]
|
||||
case .skipForward:
|
||||
MPRemoteCommandCenter.shared().skipForwardCommand.preferredIntervals = [15.0]
|
||||
default: ()
|
||||
}
|
||||
|
||||
remoteCommand.addTarget { handler(self, $0) }
|
||||
}
|
||||
|
||||
func isEnabled(_ isEnabled: Bool) {
|
||||
remoteCommand.isEnabled = isEnabled
|
||||
}
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MediaPlayer
|
||||
|
||||
struct NowPlayableStaticMetadata {
|
||||
|
||||
let mediaType: MPNowPlayingInfoMediaType
|
||||
let isLiveStream: Bool
|
||||
|
||||
let title: String
|
||||
let artist: String?
|
||||
let artwork: MPMediaItemArtwork?
|
||||
|
||||
let albumArtist: String?
|
||||
let albumTitle: String?
|
||||
|
||||
init(
|
||||
mediaType: MPNowPlayingInfoMediaType,
|
||||
isLiveStream: Bool = false,
|
||||
title: String,
|
||||
artist: String? = nil,
|
||||
artwork: MPMediaItemArtwork? = nil,
|
||||
albumArtist: String? = nil,
|
||||
albumTitle: String? = nil
|
||||
) {
|
||||
self.mediaType = mediaType
|
||||
self.isLiveStream = isLiveStream
|
||||
self.title = title
|
||||
self.artist = artist
|
||||
self.artwork = artwork
|
||||
self.albumArtist = albumArtist
|
||||
self.albumTitle = albumTitle
|
||||
}
|
||||
}
|
||||
|
||||
struct NowPlayableDynamicMetadata {
|
||||
|
||||
let rate: Float
|
||||
let position: Duration
|
||||
let duration: Duration
|
||||
|
||||
let currentLanguageOptions: [MPNowPlayingInfoLanguageOption]
|
||||
let availableLanguageOptionGroups: [MPNowPlayingInfoLanguageOptionGroup]
|
||||
|
||||
init(
|
||||
rate: Float = 1,
|
||||
position: Duration,
|
||||
duration: Duration,
|
||||
currentLanguageOptions: [MPNowPlayingInfoLanguageOption] = [],
|
||||
availableLanguageOptionGroups: [MPNowPlayingInfoLanguageOptionGroup] = []
|
||||
) {
|
||||
self.rate = rate
|
||||
self.position = position
|
||||
self.duration = duration
|
||||
self.currentLanguageOptions = currentLanguageOptions
|
||||
self.availableLanguageOptionGroups = availableLanguageOptionGroups
|
||||
}
|
||||
}
|
|
@ -0,0 +1,305 @@
|
|||
//
|
||||
// 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
|
||||
import Foundation
|
||||
import Logging
|
||||
import MediaPlayer
|
||||
import Nuke
|
||||
|
||||
// TODO: ensure proper state handling
|
||||
// - manager states
|
||||
// - playback request states
|
||||
// TODO: have MediaPlayerItem report supported commands
|
||||
|
||||
@MainActor
|
||||
class NowPlayableObserver: ViewModel, MediaPlayerObserver {
|
||||
|
||||
private var defaultRegisteredCommands: [NowPlayableCommand] {
|
||||
[
|
||||
.play,
|
||||
.pause,
|
||||
.togglePausePlay,
|
||||
.skipBackward,
|
||||
.skipForward,
|
||||
.changePlaybackPosition,
|
||||
// TODO: only register next/previous if there is a queue
|
||||
// .nextTrack,
|
||||
// .previousTrack,
|
||||
]
|
||||
}
|
||||
|
||||
private var itemImageCancellable: AnyCancellable?
|
||||
private var playbackRequestStateBeforeInterruption: MediaPlayerManager.PlaybackRequestStatus = .playing
|
||||
|
||||
weak var manager: MediaPlayerManager? {
|
||||
willSet {
|
||||
guard let newValue else { return }
|
||||
setup(with: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
private func setup(with manager: MediaPlayerManager) {
|
||||
do {
|
||||
try startSession()
|
||||
} catch {
|
||||
logger.critical("Unable to activate audio session: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
cancellables = []
|
||||
|
||||
manager.actions
|
||||
.sink { [weak self] newValue in self?.actionDidChange(newValue) }
|
||||
.store(in: &cancellables)
|
||||
|
||||
manager.$playbackItem
|
||||
.sink { [weak self] newValue in self?.playbackItemDidChange(newValue) }
|
||||
.store(in: &cancellables)
|
||||
|
||||
manager.$playbackRequestStatus
|
||||
.sink { [weak self] newValue in self?.playbackRequestStatusDidChange(newValue) }
|
||||
.store(in: &cancellables)
|
||||
|
||||
manager.secondsBox.$value
|
||||
.sink { [weak self] newValue in self?.secondsDidChange(newValue) }
|
||||
.store(in: &cancellables)
|
||||
|
||||
Notifications[.avAudioSessionInterruption]
|
||||
.publisher
|
||||
.sink { i in
|
||||
Task { @MainActor in
|
||||
self.handleInterruption(type: i.0, options: i.1)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
Task { @MainActor in
|
||||
configureRemoteCommands(
|
||||
defaultRegisteredCommands,
|
||||
commandHandler: handleCommand
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func playbackRequestStatusDidChange(_ newStatus: MediaPlayerManager.PlaybackRequestStatus) {
|
||||
handleNowPlayablePlaybackChange(
|
||||
playing: newStatus == .playing,
|
||||
metadata: .init(
|
||||
position: manager?.seconds ?? .zero,
|
||||
duration: manager?.item.runtime ?? .zero
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func secondsDidChange(_ newSeconds: Duration) {
|
||||
handleNowPlayablePlaybackChange(
|
||||
playing: true,
|
||||
metadata: .init(
|
||||
position: newSeconds,
|
||||
duration: manager?.item.runtime ?? .zero
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func actionDidChange(_ newAction: MediaPlayerManager._Action) {
|
||||
switch newAction {
|
||||
case .stop, .error:
|
||||
handleStopAction()
|
||||
default: ()
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: remove and respond to manager action publisher instead
|
||||
// TODO: register different commands based on item capabilities
|
||||
private func playbackItemDidChange(_ newItem: MediaPlayerItem?) {
|
||||
itemImageCancellable?.cancel()
|
||||
itemImageCancellable = nil
|
||||
guard let newItem else { return }
|
||||
|
||||
setNowPlayingMetadata(newItem.baseItem.nowPlayableStaticMetadata())
|
||||
|
||||
itemImageCancellable = Task {
|
||||
let currentBaseItem = newItem.baseItem
|
||||
guard let image = await newItem.thumbnailProvider?() else { return }
|
||||
guard manager?.item.id == currentBaseItem.id else { return }
|
||||
|
||||
await MainActor.run {
|
||||
setNowPlayingMetadata(
|
||||
currentBaseItem.nowPlayableStaticMetadata(image)
|
||||
)
|
||||
}
|
||||
}
|
||||
.asAnyCancellable()
|
||||
|
||||
handleNowPlayablePlaybackChange(
|
||||
playing: true,
|
||||
metadata: .init(
|
||||
position: manager?.seconds ?? .zero,
|
||||
duration: manager?.item.runtime ?? .zero
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func handleStopAction() {
|
||||
cancellables = []
|
||||
|
||||
for command in defaultRegisteredCommands {
|
||||
command.removeHandler()
|
||||
}
|
||||
|
||||
Task(priority: .userInitiated) {
|
||||
// TODO: figure out way to not need delay
|
||||
// Delay to wait for io to stop
|
||||
try? await Task.sleep(for: .seconds(0.3))
|
||||
|
||||
do {
|
||||
try stopSession()
|
||||
} catch {
|
||||
logger.critical("Unable to stop audio session: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: complete by referencing apple code
|
||||
// - restart
|
||||
@MainActor
|
||||
private func handleInterruption(
|
||||
type: AVAudioSession.InterruptionType,
|
||||
options: AVAudioSession.InterruptionOptions
|
||||
) {
|
||||
switch type {
|
||||
case .began:
|
||||
playbackRequestStateBeforeInterruption = manager?.playbackRequestStatus ?? .playing
|
||||
manager?.setPlaybackRequestStatus(status: .paused)
|
||||
case .ended:
|
||||
do {
|
||||
try startSession()
|
||||
|
||||
if playbackRequestStateBeforeInterruption == .playing {
|
||||
if options.contains(.shouldResume) {
|
||||
manager?.setPlaybackRequestStatus(status: .playing)
|
||||
} else {
|
||||
manager?.setPlaybackRequestStatus(status: .paused)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.critical("Unable to reactivate audio session after interruption: \(error.localizedDescription)")
|
||||
manager?.stop()
|
||||
}
|
||||
@unknown default: ()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleCommand(
|
||||
command: NowPlayableCommand,
|
||||
event: MPRemoteCommandEvent
|
||||
) -> MPRemoteCommandHandlerStatus {
|
||||
switch command {
|
||||
case .pause:
|
||||
manager?.setPlaybackRequestStatus(status: .paused)
|
||||
case .play:
|
||||
manager?.setPlaybackRequestStatus(status: .playing)
|
||||
case .togglePausePlay:
|
||||
manager?.togglePlayPause()
|
||||
case .skipBackward:
|
||||
guard let event = event as? MPSkipIntervalCommandEvent else { return .commandFailed }
|
||||
manager?.proxy?.jumpBackward(.seconds(event.interval))
|
||||
case .skipForward:
|
||||
guard let event = event as? MPSkipIntervalCommandEvent else { return .commandFailed }
|
||||
manager?.proxy?.jumpForward(.seconds(event.interval))
|
||||
case .changePlaybackPosition:
|
||||
guard let event = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
|
||||
manager?.proxy?.setSeconds(Duration.seconds(event.positionTime))
|
||||
case .nextTrack:
|
||||
guard let nextItem = manager?.queue?.nextItem else { return .commandFailed }
|
||||
manager?.playNewItem(provider: nextItem)
|
||||
case .previousTrack:
|
||||
guard let previousItem = manager?.queue?.previousItem else { return .commandFailed }
|
||||
manager?.playNewItem(provider: previousItem)
|
||||
default: ()
|
||||
}
|
||||
|
||||
return .success
|
||||
}
|
||||
|
||||
private func handleNowPlayablePlaybackChange(
|
||||
playing: Bool,
|
||||
metadata: NowPlayableDynamicMetadata
|
||||
) {
|
||||
setNowPlayingPlaybackInfo(metadata)
|
||||
MPNowPlayingInfoCenter.default().playbackState = playing ? .playing : .paused
|
||||
}
|
||||
|
||||
private func configureRemoteCommands(
|
||||
_ commands: [NowPlayableCommand],
|
||||
commandHandler: @escaping (NowPlayableCommand, MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus
|
||||
) {
|
||||
guard commands.isNotEmpty else { return }
|
||||
|
||||
for command in commands {
|
||||
command.addHandler(commandHandler)
|
||||
command.isEnabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
private func setNowPlayingMetadata(_ metadata: NowPlayableStaticMetadata) {
|
||||
|
||||
let nowPlayingInfoCenter = MPNowPlayingInfoCenter.default()
|
||||
var nowPlayingInfo: [String: Any] = [:]
|
||||
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = metadata.mediaType.rawValue
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyIsLiveStream] = metadata.isLiveStream
|
||||
nowPlayingInfo[MPMediaItemPropertyTitle] = metadata.title
|
||||
nowPlayingInfo[MPMediaItemPropertyArtist] = metadata.artist
|
||||
nowPlayingInfo[MPMediaItemPropertyArtwork] = metadata.artwork
|
||||
nowPlayingInfo[MPMediaItemPropertyAlbumArtist] = metadata.albumArtist
|
||||
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = metadata.albumTitle
|
||||
|
||||
nowPlayingInfoCenter.nowPlayingInfo = nowPlayingInfo
|
||||
}
|
||||
|
||||
private func setNowPlayingPlaybackInfo(_ metadata: NowPlayableDynamicMetadata) {
|
||||
|
||||
let nowPlayingInfoCenter = MPNowPlayingInfoCenter.default()
|
||||
var nowPlayingInfo: [String: Any] = nowPlayingInfoCenter.nowPlayingInfo ?? [:]
|
||||
|
||||
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = Float(metadata.duration.seconds)
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = Float(metadata.position.seconds)
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = metadata.rate
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyDefaultPlaybackRate] = 1.0
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyCurrentLanguageOptions] = metadata.currentLanguageOptions
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyAvailableLanguageOptions] = metadata.availableLanguageOptionGroups
|
||||
|
||||
nowPlayingInfoCenter.nowPlayingInfo = nowPlayingInfo
|
||||
}
|
||||
|
||||
private func startSession() throws {
|
||||
|
||||
let audioSession = AVAudioSession.sharedInstance()
|
||||
|
||||
do {
|
||||
try audioSession.setCategory(.playback, mode: .default)
|
||||
try audioSession.setActive(true)
|
||||
logger.trace("Started AVAudioSession")
|
||||
} catch {
|
||||
logger.critical("Unable to activate AVAudioSession instance: \(error.localizedDescription)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func stopSession() throws {
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(false)
|
||||
logger.trace("Stopped AVAudioSession")
|
||||
} catch {
|
||||
logger.critical("Unable to deactivate AVAudioSession instance: \(error.localizedDescription)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
//
|
||||
// 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 Get
|
||||
import JellyfinAPI
|
||||
import UIKit
|
||||
|
||||
// TODO: preload chapter images
|
||||
// - somehow tell player if there are no images
|
||||
// and don't present popup overlay
|
||||
// TODO: just use Nuke image pipeline
|
||||
|
||||
class ChapterPreviewImageProvider: PreviewImageProvider {
|
||||
|
||||
let chapters: [ChapterInfo.FullInfo]
|
||||
|
||||
@MainActor
|
||||
private var images: [Int: UIImage] = [:]
|
||||
@MainActor
|
||||
private var imageTasks: [Int: Task<UIImage?, Never>] = [:]
|
||||
|
||||
init(chapters: [ChapterInfo.FullInfo]) {
|
||||
self.chapters = chapters
|
||||
}
|
||||
|
||||
func imageIndex(for seconds: Duration) -> Int? {
|
||||
guard let currentChapterIndex = chapters
|
||||
.firstIndex(where: {
|
||||
guard let startSeconds = $0.chapterInfo.startSeconds else { return false }
|
||||
return startSeconds > seconds
|
||||
}
|
||||
) else { return nil }
|
||||
|
||||
return max(0, currentChapterIndex - 1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func image(for seconds: Duration) async -> UIImage? {
|
||||
guard let chapterIndex = imageIndex(for: seconds) else { return nil }
|
||||
|
||||
if let image = images[chapterIndex] {
|
||||
return image
|
||||
}
|
||||
|
||||
if let task = imageTasks[chapterIndex] {
|
||||
return await task.value
|
||||
}
|
||||
|
||||
let newTask = Task<UIImage?, Never> {
|
||||
let client = Container.shared.currentUserSession()!.client
|
||||
|
||||
guard let chapterInfo = chapters[safe: chapterIndex], let imageUrl = chapterInfo.imageSource.url else { return nil }
|
||||
let request: Request<Data> = .init(url: imageUrl)
|
||||
|
||||
guard let response = try? await client.send(request) else { return nil }
|
||||
guard let image = UIImage(data: response.value) else { return nil }
|
||||
|
||||
return image
|
||||
}
|
||||
|
||||
imageTasks[chapterIndex] = newTask
|
||||
return await newTask.value
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
protocol PreviewImageProvider: ObservableObject {
|
||||
func image(for seconds: Duration) async -> UIImage?
|
||||
func imageIndex(for seconds: Duration) -> Int?
|
||||
}
|
|
@ -0,0 +1,152 @@
|
|||
//
|
||||
// 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 JellyfinAPI
|
||||
import UIKit
|
||||
|
||||
// TODO: preload adjacent images
|
||||
// TODO: don't just select first trickplayinfo
|
||||
|
||||
class TrickplayPreviewImageProvider: PreviewImageProvider {
|
||||
|
||||
private struct TrickplayImage {
|
||||
|
||||
let image: UIImage
|
||||
let secondsRange: ClosedRange<Duration>
|
||||
|
||||
let columns: Int
|
||||
let rows: Int
|
||||
let tileInterval: Duration
|
||||
|
||||
func tile(for seconds: Duration) -> UIImage? {
|
||||
guard secondsRange.contains(seconds) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let index = Int(((seconds - secondsRange.lowerBound) / tileInterval).rounded(.down))
|
||||
let tileImage = image.getTileImage(columns: columns, rows: rows, index: index)
|
||||
return tileImage
|
||||
}
|
||||
}
|
||||
|
||||
private let info: TrickplayInfo
|
||||
private let itemID: String
|
||||
private let mediaSourceID: String
|
||||
private let runtime: Duration
|
||||
|
||||
@MainActor
|
||||
private var imageTasks: [Int: Task<TrickplayImage?, Never>] = [:]
|
||||
|
||||
init(
|
||||
info: TrickplayInfo,
|
||||
itemID: String,
|
||||
mediaSourceID: String,
|
||||
runtime: Duration
|
||||
) {
|
||||
self.info = info
|
||||
self.itemID = itemID
|
||||
self.mediaSourceID = mediaSourceID
|
||||
self.runtime = runtime
|
||||
}
|
||||
|
||||
func imageIndex(for seconds: Duration) -> Int? {
|
||||
let intervalIndex = Int(seconds / Duration.milliseconds(info.interval ?? 1000))
|
||||
return intervalIndex
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func image(for seconds: Duration) async -> UIImage? {
|
||||
let rows = info.tileHeight ?? 0
|
||||
let columns = info.tileWidth ?? 0
|
||||
let area = rows * columns
|
||||
let intervalIndex = Int(seconds / Duration.milliseconds(info.interval ?? 1000))
|
||||
let imageIndex = intervalIndex / area
|
||||
|
||||
if let task = imageTasks[imageIndex] {
|
||||
guard let image = await task.value else { return nil }
|
||||
return image.tile(for: seconds)
|
||||
}
|
||||
|
||||
let interval = info.interval ?? 0
|
||||
let tileImageDuration = Duration.milliseconds(
|
||||
Double(interval * rows * columns)
|
||||
)
|
||||
let tileInterval = Duration.milliseconds(interval)
|
||||
|
||||
let currentImageTask = task(
|
||||
imageIndex: imageIndex,
|
||||
tileImageDuration: tileImageDuration,
|
||||
columns: columns,
|
||||
rows: rows,
|
||||
tileInterval: tileInterval
|
||||
)
|
||||
|
||||
if imageIndex > 1, !imageTasks.keys.contains(imageIndex - 1) {
|
||||
let previousIndexTask = task(
|
||||
imageIndex: imageIndex - 1,
|
||||
tileImageDuration: tileImageDuration,
|
||||
columns: columns,
|
||||
rows: rows,
|
||||
tileInterval: tileInterval
|
||||
)
|
||||
imageTasks[imageIndex - 1] = previousIndexTask
|
||||
}
|
||||
|
||||
if seconds < (runtime - tileImageDuration), !imageTasks.keys.contains(imageIndex + 1) {
|
||||
let nextIndexTask = task(
|
||||
imageIndex: imageIndex + 1,
|
||||
tileImageDuration: tileImageDuration,
|
||||
columns: columns,
|
||||
rows: rows,
|
||||
tileInterval: tileInterval
|
||||
)
|
||||
imageTasks[imageIndex + 1] = nextIndexTask
|
||||
}
|
||||
|
||||
imageTasks[imageIndex] = currentImageTask
|
||||
|
||||
guard let image = await currentImageTask.value else { return nil }
|
||||
return image.tile(for: seconds)
|
||||
}
|
||||
|
||||
private func task(
|
||||
imageIndex: Int,
|
||||
tileImageDuration: Duration,
|
||||
columns: Int,
|
||||
rows: Int,
|
||||
tileInterval: Duration
|
||||
) -> Task<TrickplayImage?, Never> {
|
||||
Task<TrickplayImage?, Never> { [weak self] () -> TrickplayImage? in
|
||||
guard let tileWidth = self?.info.width else { return nil }
|
||||
guard let itemID = self?.itemID else { return nil }
|
||||
|
||||
let client = Container.shared.currentUserSession()!.client
|
||||
let request = Paths.getTrickplayTileImage(
|
||||
itemID: itemID,
|
||||
width: tileWidth,
|
||||
index: imageIndex
|
||||
)
|
||||
guard let response = try? await client.send(request) else { return nil }
|
||||
guard let image = UIImage(data: response.value) else { return nil }
|
||||
|
||||
let secondsRangeStart = tileImageDuration * Double(imageIndex)
|
||||
let secondsRangeEnd = secondsRangeStart + tileImageDuration
|
||||
|
||||
let trickplayImage = TrickplayImage(
|
||||
image: image,
|
||||
secondsRange: secondsRangeStart ... secondsRangeEnd,
|
||||
columns: columns,
|
||||
rows: rows,
|
||||
tileInterval: tileInterval
|
||||
)
|
||||
|
||||
return trickplayImage
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,547 @@
|
|||
//
|
||||
// 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 CollectionHStack
|
||||
import CollectionVGrid
|
||||
import Combine
|
||||
import Defaults
|
||||
import Foundation
|
||||
import IdentifiedCollections
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
// TODO: loading, error states
|
||||
// TODO: watched/status indicators
|
||||
// TODO: sometimes safe area for CollectionHStack doesn't trigger
|
||||
|
||||
@MainActor
|
||||
class EpisodeMediaPlayerQueue: ViewModel, MediaPlayerQueue {
|
||||
|
||||
weak var manager: MediaPlayerManager? {
|
||||
didSet {
|
||||
cancellables = []
|
||||
guard let manager else { return }
|
||||
manager.$playbackItem
|
||||
.sink { [weak self] newItem in
|
||||
self?.didReceive(newItem: newItem)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
|
||||
let displayTitle: String = L10n.episodes
|
||||
let id: String = "EpisodeMediaPlayerQueue"
|
||||
|
||||
@Published
|
||||
var nextItem: MediaPlayerItemProvider? = nil
|
||||
@Published
|
||||
var previousItem: MediaPlayerItemProvider? = nil
|
||||
|
||||
@Published
|
||||
var hasNextItem: Bool = false
|
||||
@Published
|
||||
var hasPreviousItem: Bool = false
|
||||
|
||||
lazy var hasNextItemPublisher: Published<Bool>.Publisher = $hasNextItem
|
||||
lazy var hasPreviousItemPublisher: Published<Bool>.Publisher = $hasPreviousItem
|
||||
lazy var nextItemPublisher: Published<MediaPlayerItemProvider?>.Publisher = $nextItem
|
||||
lazy var previousItemPublisher: Published<MediaPlayerItemProvider?>.Publisher = $previousItem
|
||||
|
||||
private var currentAdjacentEpisodesTask: AnyCancellable?
|
||||
private let seriesViewModel: SeriesItemViewModel
|
||||
|
||||
init(episode: BaseItemDto) {
|
||||
self.seriesViewModel = SeriesItemViewModel(episode: episode)
|
||||
super.init()
|
||||
|
||||
seriesViewModel.send(.refresh)
|
||||
}
|
||||
|
||||
var videoPlayerBody: some PlatformView {
|
||||
EpisodeOverlay(viewModel: seriesViewModel)
|
||||
}
|
||||
|
||||
private func didReceive(newItem: MediaPlayerItem?) {
|
||||
self.currentAdjacentEpisodesTask = Task {
|
||||
await MainActor.run {
|
||||
self.nextItem = nil
|
||||
self.previousItem = nil
|
||||
self.hasNextItem = false
|
||||
self.hasPreviousItem = false
|
||||
}
|
||||
|
||||
try await self.getAdjacentEpisodes(for: newItem?.baseItem)
|
||||
}
|
||||
.asAnyCancellable()
|
||||
}
|
||||
|
||||
private func getAdjacentEpisodes(for item: BaseItemDto?) async throws {
|
||||
guard let item else { return }
|
||||
guard let seriesID = item.seriesID, item.type == .episode else { return }
|
||||
|
||||
let parameters = Paths.GetEpisodesParameters(
|
||||
userID: userSession.user.id,
|
||||
fields: .MinimumFields,
|
||||
adjacentTo: item.id!,
|
||||
limit: 3
|
||||
)
|
||||
let request = Paths.getEpisodes(seriesID: seriesID, parameters: parameters)
|
||||
let response = try await userSession.client.send(request)
|
||||
|
||||
// 4 possible states:
|
||||
// 1 - only current episode
|
||||
// 2 - two episodes with next episode
|
||||
// 3 - two episodes with previous episode
|
||||
// 4 - three episodes with current in middle
|
||||
|
||||
// 1
|
||||
guard let items = response.value.items, items.count > 1 else { return }
|
||||
|
||||
var previousItem: BaseItemDto?
|
||||
var nextItem: BaseItemDto?
|
||||
|
||||
if items.count == 2 {
|
||||
if items[0].id == item.id {
|
||||
// 2
|
||||
nextItem = items[1]
|
||||
|
||||
} else {
|
||||
// 3
|
||||
previousItem = items[0]
|
||||
}
|
||||
} else {
|
||||
nextItem = items[2]
|
||||
previousItem = items[0]
|
||||
}
|
||||
|
||||
var nextProvider: MediaPlayerItemProvider?
|
||||
var previousProvider: MediaPlayerItemProvider?
|
||||
|
||||
if let nextItem {
|
||||
nextProvider = MediaPlayerItemProvider(item: nextItem) { item in
|
||||
try await MediaPlayerItem.build(for: item) {
|
||||
$0.userData?.playbackPositionTicks = .zero
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let previousItem {
|
||||
previousProvider = MediaPlayerItemProvider(item: previousItem) { item in
|
||||
try await MediaPlayerItem.build(for: item) {
|
||||
$0.userData?.playbackPositionTicks = .zero
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
await MainActor.run {
|
||||
self.nextItem = nextProvider
|
||||
self.previousItem = previousProvider
|
||||
self.hasNextItem = nextProvider != nil
|
||||
self.hasPreviousItem = previousProvider != nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension EpisodeMediaPlayerQueue {
|
||||
|
||||
private struct EpisodeOverlay: PlatformView {
|
||||
|
||||
@EnvironmentObject
|
||||
private var containerState: VideoPlayerContainerState
|
||||
@EnvironmentObject
|
||||
private var manager: MediaPlayerManager
|
||||
|
||||
@ObservedObject
|
||||
var viewModel: SeriesItemViewModel
|
||||
|
||||
@State
|
||||
private var selection: SeasonItemViewModel.ID?
|
||||
|
||||
private var selectionViewModel: SeasonItemViewModel? {
|
||||
guard let selection else { return nil }
|
||||
return viewModel.seasons[id: selection]
|
||||
}
|
||||
|
||||
private func select(episode: BaseItemDto) {
|
||||
let provider = MediaPlayerItemProvider(item: episode) { item in
|
||||
let mediaSource = item.mediaSources?.first
|
||||
|
||||
return try await MediaPlayerItem.build(
|
||||
for: item,
|
||||
mediaSource: mediaSource!
|
||||
)
|
||||
}
|
||||
|
||||
manager.playNewItem(provider: provider)
|
||||
}
|
||||
|
||||
var tvOSView: some View { EmptyView() }
|
||||
|
||||
var iOSView: some View {
|
||||
CompactOrRegularView(
|
||||
isCompact: containerState.isCompact
|
||||
) {
|
||||
CompactSeasonStackObserver(
|
||||
selection: $selection,
|
||||
action: select
|
||||
)
|
||||
} regularView: {
|
||||
RegularSeasonStackObserver(
|
||||
selection: $selection,
|
||||
action: select
|
||||
)
|
||||
}
|
||||
.environmentObject(viewModel)
|
||||
.onAppear {
|
||||
if let seasonID = manager.item.seasonID, let season = viewModel.seasons[id: seasonID] {
|
||||
if season.elements.isEmpty {
|
||||
season.send(.refresh)
|
||||
}
|
||||
selection = season.id
|
||||
} else {
|
||||
selection = viewModel.seasons.first?.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct CompactSeasonStackObserver: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var seriesViewModel: SeriesItemViewModel
|
||||
|
||||
private let selection: Binding<SeasonItemViewModel.ID?>
|
||||
private let action: (BaseItemDto) -> Void
|
||||
|
||||
private var selectionViewModel: SeasonItemViewModel? {
|
||||
guard let id = selection.wrappedValue else { return nil }
|
||||
return seriesViewModel.seasons[id: id]
|
||||
}
|
||||
|
||||
init(
|
||||
selection: Binding<SeasonItemViewModel.ID?>,
|
||||
action: @escaping (BaseItemDto) -> Void
|
||||
) {
|
||||
self.selection = selection
|
||||
self.action = action
|
||||
}
|
||||
|
||||
private struct _Body: View {
|
||||
|
||||
@ObservedObject
|
||||
var selectionViewModel: SeasonItemViewModel
|
||||
|
||||
let action: (BaseItemDto) -> Void
|
||||
|
||||
var body: some View {
|
||||
CollectionVGrid(
|
||||
uniqueElements: selectionViewModel.elements,
|
||||
layout: .columns(
|
||||
1,
|
||||
insets: .init(top: 0, leading: 0, bottom: EdgeInsets.edgePadding, trailing: 0)
|
||||
)
|
||||
) { item in
|
||||
EpisodeRow(episode: item) {
|
||||
action(item)
|
||||
}
|
||||
.edgePadding(.horizontal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let selectionViewModel {
|
||||
_Body(
|
||||
selectionViewModel: selectionViewModel,
|
||||
action: action
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct RegularSeasonStackObserver: View {
|
||||
|
||||
@Environment(\.safeAreaInsets)
|
||||
private var safeAreaInsets: EdgeInsets
|
||||
|
||||
@EnvironmentObject
|
||||
private var seriesViewModel: SeriesItemViewModel
|
||||
|
||||
private let selection: Binding<SeasonItemViewModel.ID?>
|
||||
private let action: (BaseItemDto) -> Void
|
||||
|
||||
private var selectionViewModel: SeasonItemViewModel? {
|
||||
guard let id = selection.wrappedValue else { return nil }
|
||||
return seriesViewModel.seasons[id: id]
|
||||
}
|
||||
|
||||
init(
|
||||
selection: Binding<SeasonItemViewModel.ID?>,
|
||||
action: @escaping (BaseItemDto) -> Void
|
||||
) {
|
||||
self.selection = selection
|
||||
self.action = action
|
||||
}
|
||||
|
||||
private struct _Body: View {
|
||||
|
||||
@Environment(\.safeAreaInsets)
|
||||
private var safeAreaInsets: EdgeInsets
|
||||
|
||||
@ObservedObject
|
||||
var selectionViewModel: SeasonItemViewModel
|
||||
|
||||
let action: (BaseItemDto) -> Void
|
||||
|
||||
var body: some View {
|
||||
CollectionHStack(
|
||||
uniqueElements: selectionViewModel.elements,
|
||||
id: \.unwrappedIDHashOrZero
|
||||
) { item in
|
||||
EpisodeButton(episode: item) {
|
||||
action(item)
|
||||
}
|
||||
.frame(height: 150)
|
||||
}
|
||||
.insets(horizontal: max(safeAreaInsets.leading, safeAreaInsets.trailing) + EdgeInsets.edgePadding)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let selectionViewModel {
|
||||
_Body(
|
||||
selectionViewModel: selectionViewModel,
|
||||
action: action
|
||||
)
|
||||
.frame(height: 150)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: make experimental setting to enable
|
||||
private struct _ButtonStack: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var containerState: VideoPlayerContainerState
|
||||
@EnvironmentObject
|
||||
private var manager: MediaPlayerManager
|
||||
@EnvironmentObject
|
||||
private var seriesViewModel: SeriesItemViewModel
|
||||
|
||||
let selection: Binding<SeasonItemViewModel.ID?>
|
||||
let selectionViewModel: SeasonItemViewModel
|
||||
|
||||
init(
|
||||
selection: Binding<SeasonItemViewModel.ID?>,
|
||||
selectionViewModel: SeasonItemViewModel
|
||||
) {
|
||||
self.selection = selection
|
||||
self.selectionViewModel = selectionViewModel
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Menu {
|
||||
ForEach(seriesViewModel.seasons, id: \.season.id) { season in
|
||||
Button {
|
||||
selection.wrappedValue = season.id
|
||||
if season.elements.isEmpty {
|
||||
season.send(.refresh)
|
||||
}
|
||||
} label: {
|
||||
if season.id == selection.wrappedValue {
|
||||
Label(season.season.displayTitle, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(season.season.displayTitle)
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Label(selectionViewModel.season.displayTitle, systemImage: "chevron.down")
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.black)
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
|
||||
Button {
|
||||
guard let nextItem = manager.queue?.nextItem else { return }
|
||||
manager.playNewItem(provider: nextItem)
|
||||
manager.setPlaybackRequestStatus(status: .playing)
|
||||
containerState.select(supplement: nil)
|
||||
} label: {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Label("Next", systemImage: "forward.end.fill")
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.black)
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
|
||||
Button {
|
||||
guard let previousItem = manager.queue?.previousItem else { return }
|
||||
manager.playNewItem(provider: previousItem)
|
||||
manager.setPlaybackRequestStatus(status: .playing)
|
||||
containerState.select(supplement: nil)
|
||||
} label: {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Label("Previous", systemImage: "backward.end.fill")
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.black)
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
.frame(width: 150)
|
||||
.edgePadding(.horizontal)
|
||||
// .padding(.trailing, safeAreaInsets.trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct EpisodePreview: View {
|
||||
|
||||
@Default(.accentColor)
|
||||
private var accentColor
|
||||
|
||||
@Environment(\.isSelected)
|
||||
private var isSelected: Bool
|
||||
|
||||
let episode: BaseItemDto
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Rectangle()
|
||||
.fill(.complexSecondary)
|
||||
|
||||
ImageView(episode.imageSource(.primary, maxWidth: 200))
|
||||
.failure {
|
||||
SystemImageContentView(systemName: episode.systemImage)
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if isSelected {
|
||||
ContainerRelativeShape()
|
||||
.stroke(
|
||||
accentColor,
|
||||
lineWidth: 8
|
||||
)
|
||||
.clipped()
|
||||
}
|
||||
}
|
||||
.posterStyle(.landscape)
|
||||
}
|
||||
}
|
||||
|
||||
private struct EpisodeDescription: View {
|
||||
|
||||
let episode: BaseItemDto
|
||||
|
||||
var body: some View {
|
||||
DotHStack {
|
||||
if let seasonEpisodeLabel = episode.seasonEpisodeLabel {
|
||||
Text(seasonEpisodeLabel)
|
||||
}
|
||||
|
||||
if let runtime = episode.runTimeLabel {
|
||||
Text(runtime)
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private struct EpisodeRow: View {
|
||||
|
||||
@Default(.accentColor)
|
||||
private var accentColor
|
||||
|
||||
@EnvironmentObject
|
||||
private var manager: MediaPlayerManager
|
||||
|
||||
let episode: BaseItemDto
|
||||
let action: () -> Void
|
||||
|
||||
private var isCurrentEpisode: Bool {
|
||||
manager.item.id == episode.id
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ListRow(insets: .init(horizontal: EdgeInsets.edgePadding)) {
|
||||
EpisodePreview(episode: episode)
|
||||
.frame(width: 110)
|
||||
.padding(.vertical, 8)
|
||||
} content: {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(episode.displayTitle)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
EpisodeDescription(episode: episode)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.onSelect(perform: action)
|
||||
.isSelected(isCurrentEpisode)
|
||||
}
|
||||
}
|
||||
|
||||
private struct EpisodeButton: View {
|
||||
|
||||
@Default(.accentColor)
|
||||
private var accentColor
|
||||
|
||||
@EnvironmentObject
|
||||
private var manager: MediaPlayerManager
|
||||
|
||||
let episode: BaseItemDto
|
||||
let action: () -> Void
|
||||
|
||||
private var isCurrentEpisode: Bool {
|
||||
manager.item.id == episode.id
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
EpisodePreview(episode: episode)
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(episode.displayTitle)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(.primary)
|
||||
.frame(height: 15)
|
||||
|
||||
EpisodeDescription(episode: episode)
|
||||
.frame(height: 20, alignment: .top)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.primary, .secondary)
|
||||
.isSelected(isCurrentEpisode)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,275 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import CollectionHStack
|
||||
import CollectionVGrid
|
||||
import Defaults
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
// TODO: current button
|
||||
// TODO: scroll to current chapter on appear
|
||||
// TODO: fix swapping between chapters on selection
|
||||
// - little flicker at seconds boundary
|
||||
// TODO: sometimes safe area for CollectionHStack doesn't trigger
|
||||
// TODO: fix chapter image aspect fit
|
||||
// - still be in a 1.77 box
|
||||
|
||||
class MediaChaptersSupplement: ObservableObject, MediaPlayerSupplement {
|
||||
|
||||
let chapters: [ChapterInfo.FullInfo]
|
||||
let displayTitle: String = L10n.chapters
|
||||
let id: String
|
||||
|
||||
init(chapters: [ChapterInfo.FullInfo]) {
|
||||
self.chapters = chapters
|
||||
self.id = "Chapters-\(chapters.hashValue)"
|
||||
}
|
||||
|
||||
func isCurrentChapter(seconds: Duration, chapter: ChapterInfo.FullInfo) -> Bool {
|
||||
guard let currentChapterIndex = chapters
|
||||
.firstIndex(where: {
|
||||
guard let startSeconds = $0.chapterInfo.startSeconds else { return false }
|
||||
return startSeconds > seconds
|
||||
}
|
||||
) else { return false }
|
||||
|
||||
guard let currentChapter = chapters[safe: max(0, currentChapterIndex - 1)] else { return false }
|
||||
return currentChapter.id == chapter.id
|
||||
}
|
||||
|
||||
var videoPlayerBody: some PlatformView {
|
||||
ChapterOverlay(supplement: self)
|
||||
}
|
||||
}
|
||||
|
||||
extension MediaChaptersSupplement {
|
||||
|
||||
private struct ChapterOverlay: PlatformView {
|
||||
|
||||
@Environment(\.safeAreaInsets)
|
||||
private var safeAreaInsets: EdgeInsets
|
||||
|
||||
@EnvironmentObject
|
||||
private var containerState: VideoPlayerContainerState
|
||||
@EnvironmentObject
|
||||
private var manager: MediaPlayerManager
|
||||
|
||||
@ObservedObject
|
||||
private var supplement: MediaChaptersSupplement
|
||||
|
||||
@StateObject
|
||||
private var collectionHStackProxy: CollectionHStackProxy = .init()
|
||||
|
||||
init(supplement: MediaChaptersSupplement) {
|
||||
self.supplement = supplement
|
||||
}
|
||||
|
||||
private var chapters: [ChapterInfo.FullInfo] {
|
||||
supplement.chapters
|
||||
}
|
||||
|
||||
private var currentChapter: ChapterInfo.FullInfo? {
|
||||
chapters.first(
|
||||
where: {
|
||||
guard let startSeconds = $0.chapterInfo.startSeconds else { return false }
|
||||
return startSeconds <= manager.seconds
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var iOSView: some View {
|
||||
CompactOrRegularView(
|
||||
isCompact: containerState.isCompact
|
||||
) {
|
||||
iOSCompactView
|
||||
} regularView: {
|
||||
iOSRegularView
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var iOSCompactView: some View {
|
||||
// TODO: scroll to current chapter
|
||||
CollectionVGrid(
|
||||
uniqueElements: chapters,
|
||||
layout: .columns(
|
||||
1,
|
||||
insets: .init(top: 0, leading: 0, bottom: EdgeInsets.edgePadding, trailing: 0)
|
||||
)
|
||||
) { chapter, _ in
|
||||
ChapterRow(chapter: chapter) {
|
||||
guard let startSeconds = chapter.chapterInfo.startSeconds else { return }
|
||||
manager.proxy?.setSeconds(startSeconds)
|
||||
manager.setPlaybackRequestStatus(status: .playing)
|
||||
}
|
||||
.edgePadding(.horizontal)
|
||||
.environmentObject(supplement)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var iOSRegularView: some View {
|
||||
// TODO: change to continuousLeadingEdge after
|
||||
// layout inset fix in CollectionHStack
|
||||
CollectionHStack(
|
||||
uniqueElements: chapters
|
||||
) { chapter in
|
||||
ChapterButton(chapter: chapter) {
|
||||
guard let startSeconds = chapter.chapterInfo.startSeconds else { return }
|
||||
manager.proxy?.setSeconds(startSeconds)
|
||||
manager.setPlaybackRequestStatus(status: .playing)
|
||||
}
|
||||
.frame(height: 150)
|
||||
.environmentObject(supplement)
|
||||
}
|
||||
.insets(horizontal: max(safeAreaInsets.leading, safeAreaInsets.trailing) + EdgeInsets.edgePadding)
|
||||
.proxy(collectionHStackProxy)
|
||||
.frame(height: 150)
|
||||
.onAppear {
|
||||
guard let currentChapter else { return }
|
||||
collectionHStackProxy.scrollTo(id: currentChapter.id)
|
||||
}
|
||||
}
|
||||
|
||||
var tvOSView: some View { EmptyView() }
|
||||
}
|
||||
|
||||
struct ChapterPreview: View {
|
||||
|
||||
@Default(.accentColor)
|
||||
private var accentColor
|
||||
|
||||
@Environment(\.isSelected)
|
||||
private var isSelected
|
||||
|
||||
let chapter: ChapterInfo.FullInfo
|
||||
|
||||
var body: some View {
|
||||
PosterImage(
|
||||
item: chapter,
|
||||
type: .landscape,
|
||||
contentMode: .fill
|
||||
)
|
||||
.overlay {
|
||||
if isSelected {
|
||||
ContainerRelativeShape()
|
||||
.stroke(
|
||||
accentColor,
|
||||
lineWidth: 8
|
||||
)
|
||||
.clipped()
|
||||
}
|
||||
}
|
||||
.posterStyle(.landscape)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChapterContent: View {
|
||||
|
||||
let chapter: ChapterInfo.FullInfo
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(chapter.chapterInfo.displayTitle)
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(.white)
|
||||
.frame(height: 15)
|
||||
|
||||
Text(chapter.chapterInfo.startSeconds ?? .zero, format: .runtime)
|
||||
.frame(height: 20)
|
||||
.foregroundStyle(Color(UIColor.systemBlue))
|
||||
.padding(.horizontal, 4)
|
||||
.background {
|
||||
Color(.darkGray)
|
||||
.opacity(0.2)
|
||||
.cornerRadius(4)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChapterRow: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var manager: MediaPlayerManager
|
||||
@EnvironmentObject
|
||||
private var supplement: MediaChaptersSupplement
|
||||
|
||||
@State
|
||||
private var activeSeconds: Duration = .zero
|
||||
|
||||
let chapter: ChapterInfo.FullInfo
|
||||
let action: () -> Void
|
||||
|
||||
private var isCurrentChapter: Bool {
|
||||
supplement.isCurrentChapter(
|
||||
seconds: activeSeconds,
|
||||
chapter: chapter
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ListRow(insets: .init(horizontal: EdgeInsets.edgePadding)) {
|
||||
ChapterPreview(
|
||||
chapter: chapter
|
||||
)
|
||||
.frame(width: 110)
|
||||
.padding(.vertical, 8)
|
||||
} content: {
|
||||
ChapterContent(chapter: chapter)
|
||||
}
|
||||
.onSelect(perform: action)
|
||||
.assign(manager.secondsBox.$value, to: $activeSeconds)
|
||||
.isSelected(isCurrentChapter)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChapterButton: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var manager: MediaPlayerManager
|
||||
@EnvironmentObject
|
||||
private var supplement: MediaChaptersSupplement
|
||||
|
||||
@State
|
||||
private var activeSeconds: Duration = .zero
|
||||
|
||||
let chapter: ChapterInfo.FullInfo
|
||||
let action: () -> Void
|
||||
|
||||
private var isCurrentChapter: Bool {
|
||||
supplement.isCurrentChapter(
|
||||
seconds: activeSeconds,
|
||||
chapter: chapter
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
ChapterPreview(
|
||||
chapter: chapter
|
||||
)
|
||||
|
||||
ChapterContent(
|
||||
chapter: chapter
|
||||
)
|
||||
}
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.foregroundStyle(.primary, .secondary)
|
||||
.assign(manager.secondsBox.$value, to: $activeSeconds)
|
||||
.isSelected(isCurrentChapter)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,177 @@
|
|||
//
|
||||
// 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: scroll if description too long
|
||||
|
||||
struct MediaInfoSupplement: MediaPlayerSupplement {
|
||||
|
||||
let displayTitle: String = "Info"
|
||||
let item: BaseItemDto
|
||||
|
||||
var id: String {
|
||||
"MediaInfo-\(item.id ?? "any")"
|
||||
}
|
||||
|
||||
var videoPlayerBody: some PlatformView {
|
||||
InfoOverlay(item: item)
|
||||
}
|
||||
}
|
||||
|
||||
extension MediaInfoSupplement {
|
||||
|
||||
private struct InfoOverlay: PlatformView {
|
||||
|
||||
@Environment(\.safeAreaInsets)
|
||||
private var safeAreaInsets: EdgeInsets
|
||||
|
||||
@EnvironmentObject
|
||||
private var containerState: VideoPlayerContainerState
|
||||
@EnvironmentObject
|
||||
private var manager: MediaPlayerManager
|
||||
|
||||
let item: BaseItemDto
|
||||
|
||||
@ViewBuilder
|
||||
private var accessoryView: some View {
|
||||
DotHStack {
|
||||
if item.type == .episode, let seasonEpisodeLocator = item.seasonEpisodeLabel {
|
||||
Text(seasonEpisodeLocator)
|
||||
} else if let premiereYear = item.premiereDateYear {
|
||||
Text(premiereYear)
|
||||
}
|
||||
|
||||
if let runtime = item.runTimeLabel {
|
||||
Text(runtime)
|
||||
}
|
||||
|
||||
if let officialRating = item.officialRating {
|
||||
Text(officialRating)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var fromBeginningButton: some View {
|
||||
Button("From Beginning", systemImage: "play.fill") {
|
||||
manager.proxy?.setSeconds(.zero)
|
||||
manager.setPlaybackRequestStatus(status: .playing)
|
||||
containerState.select(supplement: nil)
|
||||
}
|
||||
#if os(iOS)
|
||||
.buttonStyle(.material)
|
||||
#endif
|
||||
.frame(width: 200, height: 50)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
|
||||
// TODO: may need to be a layout for correct overview frame
|
||||
// with scrolling if too long
|
||||
var iOSView: some View {
|
||||
CompactOrRegularView(
|
||||
isCompact: containerState.isCompact
|
||||
) {
|
||||
iOSCompactView
|
||||
} regularView: {
|
||||
iOSRegularView
|
||||
}
|
||||
.padding(.leading, safeAreaInsets.leading)
|
||||
.padding(.trailing, safeAreaInsets.trailing)
|
||||
.edgePadding(.horizontal)
|
||||
.edgePadding(.bottom)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var iOSCompactView: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Group {
|
||||
Text(item.displayTitle)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
if let overview = item.overview {
|
||||
Text(overview)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.regular)
|
||||
}
|
||||
|
||||
accessoryView
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
|
||||
if !item.isLiveStream {
|
||||
Button {
|
||||
manager.proxy?.setSeconds(.zero)
|
||||
manager.setPlaybackRequestStatus(status: .playing)
|
||||
containerState.select(supplement: nil)
|
||||
} label: {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Label("From Beginning", systemImage: "play.fill")
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.black)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 40)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var iOSRegularView: some View {
|
||||
HStack(alignment: .bottom, spacing: EdgeInsets.edgePadding) {
|
||||
// TODO: determine what to do with non-portrait (channel, home video) images
|
||||
// - use aspect ratio?
|
||||
PosterImage(
|
||||
item: item,
|
||||
type: item.preferredPosterDisplayType,
|
||||
contentMode: .fit
|
||||
)
|
||||
.environment(\.isOverComplexContent, true)
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(item.displayTitle)
|
||||
.font(.callout)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
if let overview = item.overview {
|
||||
Text(overview)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.regular)
|
||||
.lineLimit(3)
|
||||
}
|
||||
|
||||
accessoryView
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
if !item.isLiveStream {
|
||||
VStack {
|
||||
fromBeginningButton
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var tvOSView: some View { EmptyView() }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,91 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
protocol MediaPlayerQueue: ObservableObject, MediaPlayerObserver, MediaPlayerSupplement {
|
||||
|
||||
var hasNextItem: Bool { get }
|
||||
var hasPreviousItem: Bool { get }
|
||||
|
||||
var nextItem: MediaPlayerItemProvider? { get }
|
||||
var previousItem: MediaPlayerItemProvider? { get }
|
||||
|
||||
var hasNextItemPublisher: Published<Bool>.Publisher { get set }
|
||||
var hasPreviousItemPublisher: Published<Bool>.Publisher { get set }
|
||||
var nextItemPublisher: Published<MediaPlayerItemProvider?>.Publisher { get set }
|
||||
var previousItemPublisher: Published<MediaPlayerItemProvider?>.Publisher { get set }
|
||||
}
|
||||
|
||||
extension MediaPlayerQueue {
|
||||
|
||||
var hasNextItem: Bool {
|
||||
nextItem != nil
|
||||
}
|
||||
|
||||
var hasPreviousItem: Bool {
|
||||
previousItem != nil
|
||||
}
|
||||
}
|
||||
|
||||
class AnyMediaPlayerQueue: MediaPlayerQueue {
|
||||
|
||||
@Published
|
||||
var hasNextItem: Bool
|
||||
@Published
|
||||
var hasPreviousItem: Bool
|
||||
|
||||
@Published
|
||||
var nextItem: MediaPlayerItemProvider?
|
||||
@Published
|
||||
var previousItem: MediaPlayerItemProvider?
|
||||
|
||||
lazy var hasNextItemPublisher: Published<Bool>.Publisher = $hasNextItem
|
||||
lazy var hasPreviousItemPublisher: Published<Bool>.Publisher = $hasPreviousItem
|
||||
lazy var nextItemPublisher: Published<MediaPlayerItemProvider?>.Publisher = $nextItem
|
||||
lazy var previousItemPublisher: Published<MediaPlayerItemProvider?>.Publisher = $previousItem
|
||||
|
||||
private var wrapped: any MediaPlayerQueue
|
||||
|
||||
var displayTitle: String {
|
||||
wrapped.displayTitle
|
||||
}
|
||||
|
||||
var id: String {
|
||||
wrapped.id
|
||||
}
|
||||
|
||||
weak var manager: MediaPlayerManager? {
|
||||
get { wrapped.manager }
|
||||
set { wrapped.manager = newValue }
|
||||
}
|
||||
|
||||
private var cancellables: [AnyCancellable] = []
|
||||
|
||||
init(_ wrapped: some MediaPlayerQueue) {
|
||||
self.wrapped = wrapped
|
||||
self.hasNextItem = wrapped.hasNextItem
|
||||
self.hasPreviousItem = wrapped.hasPreviousItem
|
||||
|
||||
wrapped.hasNextItemPublisher
|
||||
.assign(to: &$hasNextItem)
|
||||
wrapped.hasPreviousItemPublisher
|
||||
.assign(to: &$hasPreviousItem)
|
||||
wrapped.nextItemPublisher
|
||||
.assign(to: &$nextItem)
|
||||
wrapped.previousItemPublisher
|
||||
.assign(to: &$previousItem)
|
||||
}
|
||||
|
||||
var videoPlayerBody: some PlatformView {
|
||||
wrapped
|
||||
.videoPlayerBody
|
||||
.eraseToAnyView()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
//
|
||||
// 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: fullscreen supplement styles
|
||||
|
||||
@MainActor
|
||||
protocol MediaPlayerSupplement: Displayable, Identifiable {
|
||||
|
||||
associatedtype VideoPlayerBody: PlatformView
|
||||
|
||||
var id: String { get }
|
||||
|
||||
@MainActor
|
||||
@ViewBuilder
|
||||
var videoPlayerBody: Self.VideoPlayerBody { get }
|
||||
}
|
||||
|
||||
struct AnyMediaPlayerSupplement: MediaPlayerSupplement, Equatable {
|
||||
|
||||
let supplement: any MediaPlayerSupplement
|
||||
|
||||
var displayTitle: String {
|
||||
supplement.displayTitle
|
||||
}
|
||||
|
||||
var id: String {
|
||||
supplement.id
|
||||
}
|
||||
|
||||
var videoPlayerBody: some PlatformView {
|
||||
supplement.videoPlayerBody
|
||||
.eraseToAnyView()
|
||||
}
|
||||
|
||||
init(_ supplement: any MediaPlayerSupplement) {
|
||||
self.supplement = supplement
|
||||
}
|
||||
|
||||
static func == (lhs: AnyMediaPlayerSupplement, rhs: AnyMediaPlayerSupplement) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// Observable object property wrapper that allows observing
|
||||
/// another `Publisher`.
|
||||
@propertyWrapper
|
||||
final class ObservedPublisher<Value>: ObservableObject {
|
||||
|
||||
@Published
|
||||
private(set) var wrappedValue: Value
|
||||
|
||||
var projectedValue: AnyPublisher<Value, Never> {
|
||||
$wrappedValue
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init<P: Publisher>(
|
||||
wrappedValue: Value,
|
||||
observing publisher: P
|
||||
) where P.Output == Value, P.Failure == Never {
|
||||
self.wrappedValue = wrappedValue
|
||||
|
||||
publisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] newValue in
|
||||
self?.wrappedValue = newValue
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
static subscript<T: ObservableObject>(
|
||||
_enclosingInstance instance: T,
|
||||
wrapped wrappedKeyPath: KeyPath<T, Value>,
|
||||
storage storageKeyPath: KeyPath<T, ObservedPublisher<Value>>
|
||||
) -> Value where T.ObjectWillChangePublisher == ObservableObjectPublisher {
|
||||
let wrapper = instance[keyPath: storageKeyPath]
|
||||
|
||||
wrapper.objectWillChange
|
||||
.sink { [weak instance] _ in
|
||||
instance?.objectWillChange.send()
|
||||
}
|
||||
.store(in: &wrapper.cancellables)
|
||||
|
||||
return wrapper.wrappedValue
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue