Compare commits

..

No commits in common. "09a3ce15a0d5c7dface651ac057bc8d9762d5c49" and "54154b032feffc012952325aabd10b5fb34c4ec6" have entirely different histories.

1106 changed files with 186 additions and 64569 deletions

View File

@ -1,44 +0,0 @@
---
description: Build jellypig tvOS (debug or release)
---
Build jellypig tvOS for the simulator. Takes an optional configuration argument:
- `debug` (default) - Fast build with debugging symbols
- `release` - Optimized build for distribution
Usage:
- `/build` - Build in Debug configuration (default)
- `/build debug` - Build in Debug configuration (explicit)
- `/build release` - Build in Release configuration
Steps to execute:
1. Parse the configuration argument (default to "debug" if not provided or invalid)
2. Validate the configuration is either "debug" or "release" (case-insensitive)
3. Run xcodebuild with the specified configuration:
```bash
cd /Users/ashikkizhakkepallathu/Documents/claude/jellypig/jellypig
# For debug:
xcodebuild -project jellypig.xcodeproj \
-scheme "jellypig tvOS" \
-sdk appletvsimulator \
-configuration Debug \
-derivedDataPath ./DerivedData \
clean build \
CODE_SIGNING_ALLOWED=NO
# For release:
xcodebuild -project jellypig.xcodeproj \
-scheme "jellypig tvOS" \
-sdk appletvsimulator \
-configuration Release \
-derivedDataPath ./DerivedData \
clean build \
CODE_SIGNING_ALLOWED=NO
```
4. Report build status (success or failure)
5. Display the output path of the built app
Expected output location:
- Debug: `./DerivedData/Build/Products/Debug-appletvsimulator/jellypig tvOS.app`
- Release: `./DerivedData/Build/Products/Release-appletvsimulator/jellypig tvOS.app`

View File

@ -1,17 +0,0 @@
---
description: Initialize development session by reading project context and displaying available commands
---
Read the chats-summary.txt file from the parent directory (/Users/ashikkizhakkepallathu/Documents/claude/jellypig/chats-summary.txt) to understand the project context, then display a quick summary of what you can help with.
Steps:
1. Read /Users/ashikkizhakkepallathu/Documents/claude/jellypig/chats-summary.txt
2. Display a concise summary including:
- Project name and description
- Available custom slash commands (/build, /sim, etc.)
- Recent features implemented
- Key configuration details (Bundle ID, Simulator UUID, etc.)
- Build method: **Command-line builds work** via xcodebuild (no Xcode GUI required)
- Common tasks you can help with
Make the output brief and actionable - focus on what's immediately useful for the developer.

View File

@ -1,41 +0,0 @@
---
description: Build and launch jellypig tvOS in simulator
---
Build the latest version of jellypig tvOS in Debug configuration, install it on the Apple TV simulator, and launch it.
Steps:
1. First, build the project using the same approach as `/build debug`:
```bash
cd /Users/ashikkizhakkepallathu/Documents/claude/jellypig/jellypig
xcodebuild -project jellypig.xcodeproj \
-scheme "jellypig tvOS" \
-sdk appletvsimulator \
-configuration Debug \
-derivedDataPath ./DerivedData \
clean build \
CODE_SIGNING_ALLOWED=NO
```
2. Boot the Apple TV simulator:
```bash
xcrun simctl boot 16A71179-729D-4F1B-8698-8371F137025B 2>/dev/null || true
```
3. Open Simulator.app:
```bash
open -a Simulator
```
4. Install the built app on the simulator:
```bash
xcrun simctl install 16A71179-729D-4F1B-8698-8371F137025B \
"./DerivedData/Build/Products/Debug-appletvsimulator/jellypig tvOS.app"
```
5. Launch the app:
```bash
xcrun simctl launch 16A71179-729D-4F1B-8698-8371F137025B org.ashik.jellypig
```
Report build and launch status. If any step fails, provide clear error message.

View File

@ -1,107 +0,0 @@
# jellypig tvOS Alpha 1 Release
**Release Date**: October 17, 2025
**Base Version**: Swiftfin 1.3
**Branch**: jellypig-1.3
**License**: MPL-2.0
---
## Overview
jellypig is a personal fork of [Jellyfin/Swiftfin](https://github.com/jellyfin/swiftfin) optimized for tvOS with specialized support for Jellyfin.Xtream plugin content. This alpha1 release represents a stable, working build that plays everything upstream Swiftfin on tvOS plays **and more**, with an elegant native UI maintained by the Swiftfin development team.
## Key Features
### ✅ What Works
- **Full Jellyfin Playback**: All standard Jellyfin content (movies, series, live TV) plays correctly
- **Xtream Content Support**: Properly displays and plays Xtream VOD and Series content from Jellyfin.Xtream plugin
- **Channel Navigation**: Fixed channel browsing with proper grid view for categories and content
- **Full-Screen Navigation**: All views display full-screen (no modal popups)
- **Video Playback Controls**: ESC key properly shows controls and allows exit with confirmation
- **Native UI**: Maintains the elegant, native tvOS interface from upstream Swiftfin
### 🎯 Major Improvements Over Upstream
1. **Xtream Content Listing**: Fixed channel folder navigation to use proper Jellyfin Channel API endpoints
- Xtream VOD and Xtream Series channels now display correct, distinct content
- Channel folders display in grid view matching library behavior
2. **tvOS Navigation Architecture**: Restructured routing to fix modal popup issues
- Channels, items, and content display full-screen using `.push` navigation
- MediaCoordinator uses `.fullScreen` to prevent navigation stack corruption
- Video player properly stops on dismiss (no background playback)
3. **Leaner Codebase**: Removed entire iOS build and code (28,370+ deletions)
- tvOS-only focus reduces complexity
- Faster build times and easier maintenance
- Single target: `jellypig tvOS`
## Technical Changes
### Core Fixes
- **ItemLibraryViewModel.swift**: Implements proper channel API routing (`/Channels/{channelID}/Items`)
- **PagingLibraryView.swift**: Added `.channelFolderItem` support for grid view display
- **MainCoordinator.swift**: Restructured MediaCoordinator routing with `.fullScreen` navigation
- **ItemView.swift**: Removed `.channelFolderItem` handling (delegates to library view)
### Project Configuration
- **Bundle ID**: `org.ashik.jellypig`
- **Project Name**: jellypig (renamed from Swiftfin)
- **Targets**: tvOS only (iOS removed)
- **CI/CD**: Updated GitHub Actions workflows for jellypig tvOS builds
- **Dependencies**: Properly configured VLCKit (TVVLCKit.xcframework v3.5.0)
## Known Limitations
### Acceptable Trade-offs
- **Error Dismissal**: Errors in deeply nested views (e.g., failed episode playback) dismiss all the way to Media tab instead of one level back
- This is a limitation of `.fullScreen` presentation architecture
- Prevents worse issue of returning to Home screen
- **Video Player Back Button**: Slightly "buggy" behavior but functional
- ESC key works properly (shows controls → confirm → exit)
- Playback stops correctly on dismiss
## Development Environment
### Requirements
- **Platform**: macOS with Xcode 16+
- **Build Method**: Must use Xcode GUI (Command+B)
- Command-line `xcodebuild` fails due to Swift macro issues in swift-case-paths dependency
- All other operations (git, editing) work via command line
- **Simulator**: Any tvOS 16.0+ simulator or device
### Repository
- **Fork**: [ashikslab/jellypig](https://github.com/ashikslab/jellypig)
- **Upstream**: [jellyfin/swiftfin](https://github.com/jellyfin/swiftfin)
- **Plugin**: [ashikslab/Jellyfin.Xtream](https://github.com/ashikslab/Jellyfin.Xtream)
## Companion Plugin
jellypig works best with the reverted **Jellyfin.Xtream** plugin (v0.7.2.0001) which uses pure upstream logic:
- Repository: https://ashikslab.github.io/Jellyfin.Xtream/repository.json
- Download: https://github.com/ashikslab/Jellyfin.Xtream/releases/download/v0.7.2.0001/jellyfin-xtream-for-jellypig_0.7.2.1.zip
The plugin provides standard Jellyfin Channel API endpoints for Xtream content, and jellypig handles the display and navigation correctly.
## What's Next
### Short Term (Until Upstream Stabilizes)
- Bug fixes as discovered
- UI adjustments and polish
- Stay on Swiftfin 1.3 base
### Long Term
- **Major Intake**: When upstream Swiftfin releases their next stable major version, jellypig will merge those changes
- **iOS Support**: May consider re-adding iOS support with Xtream fixes as a separate project
- **Upstream Contributions**: Channel navigation fixes could potentially be contributed back to Swiftfin
## Credits
- **Original Project**: [Jellyfin Swiftfin](https://github.com/jellyfin/swiftfin) - MPL-2.0 License
- **Plugin**: [Kevinjil/Jellyfin.Xtream](https://github.com/Kevinjil/Jellyfin.Xtream) - GPL-3.0 License
- **jellypig Fork**: Personal modifications for Xtream content support and tvOS optimization
---
**This is an alpha release**: Suitable for personal use and testing. Report issues at https://github.com/ashikslab/jellypig/issues

View File

@ -1,364 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import SwiftUI
/// A custom layout that arranges views in a flow pattern, automatically wrapping items to new rows
struct FlowLayout: Layout {
// MARK: - Fill Direction
enum Direction {
case up
case down
}
// MARK: - Cache Structure
struct CacheData {
let subviewSizes: [CGSize]
let rows: [[Int]]
let totalSize: CGSize
let lastWidth: CGFloat?
}
// MARK: - Properties
/// The alignment of content within each row (leading, center, or trailing)
private let alignment: HorizontalAlignment
/// Controls whether items fill from the top row down or bottom row up when wrapping
private let direction: Direction
/// The horizontal spacing between items within the same row
private let spacing: CGFloat
/// The vertical spacing between the top and bottom rows when content wraps
private let lineSpacing: CGFloat
/// The minimum number of items that must be in the smaller row when wrapping occurs
private let minRowLength: Int
init(
alignment: HorizontalAlignment = .center,
direction: Direction = .up,
spacing: CGFloat = 8,
lineSpacing: CGFloat = 8,
minRowLength: Int = 2
) {
self.alignment = alignment
self.direction = direction
self.spacing = spacing
self.lineSpacing = lineSpacing
self.minRowLength = minRowLength
}
// MARK: - Make Cache
func makeCache(subviews: Subviews) -> CacheData {
CacheData(
subviewSizes: [],
rows: [],
totalSize: .zero,
lastWidth: nil
)
}
// MARK: - Update Cache
func updateCache(_ cache: inout CacheData, subviews: Subviews) {
cache = CacheData(
subviewSizes: [],
rows: [],
totalSize: .zero,
lastWidth: nil
)
}
// MARK: - Calculate Layout
private func calculateLayout(
subviews: Subviews,
width: CGFloat
) -> (sizes: [CGSize], rows: [[Int]], totalSize: CGSize) {
let sizes = subviews.map { subview in
let size = subview.sizeThatFits(.unspecified)
return CGSize(width: ceil(size.width), height: ceil(size.height))
}
let rows = computeRows(sizes: sizes, maxWidth: width)
let totalSize = computeTotalSize(rows: rows, sizes: sizes)
return (sizes, rows, totalSize)
}
// MARK: - Size That Fits
/// Calculates the minimum size needed to display all subviews according to the flow layout rules
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout CacheData
) -> CGSize {
let availableWidth = proposal.width ?? .infinity
let effectiveWidth = availableWidth.isFinite ? availableWidth : 1000
if cache.lastWidth != effectiveWidth || cache.subviewSizes.isEmpty {
let (sizes, rows, totalSize) = calculateLayout(
subviews: subviews,
width: effectiveWidth
)
cache = CacheData(
subviewSizes: sizes,
rows: rows,
totalSize: totalSize,
lastWidth: effectiveWidth
)
}
// Return the calculated height but respect the proposed width
return CGSize(
width: min(cache.totalSize.width, proposal.width ?? cache.totalSize.width),
height: cache.totalSize.height
)
}
// MARK: - Place Subviews
/// Positions each subview within the given bounds according to the flow layout rules
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout CacheData
) {
let availableWidth = bounds.width
if cache.lastWidth != availableWidth || cache.subviewSizes.isEmpty {
let (sizes, rows, totalSize) = calculateLayout(
subviews: subviews,
width: availableWidth
)
cache = CacheData(
subviewSizes: sizes,
rows: rows,
totalSize: totalSize,
lastWidth: availableWidth
)
}
let sizes = cache.subviewSizes
let rows = cache.rows
var yOffset: CGFloat = bounds.minY
for row in rows {
let rowHeight = row.map { sizes[$0].height }.max() ?? 0
let rowWidth = computeRowWidth(indices: row, sizes: sizes)
let xOffset = computeXOffset(rowWidth: rowWidth, bounds: bounds)
var x = xOffset
for index in row {
let size = sizes[index]
let y = yOffset + (rowHeight - size.height) / 2
subviews[index].place(
at: CGPoint(x: x, y: y),
anchor: .topLeading,
proposal: ProposedViewSize(size)
)
x += size.width + spacing
}
yOffset += rowHeight + lineSpacing
}
}
// MARK: - Compute Rows
/// Determines how to distribute items across rows based on the available width
private func computeRows(
sizes: [CGSize],
maxWidth: CGFloat
) -> [[Int]] {
guard sizes.count > 1 else {
return sizes.isEmpty ? [] : [[0]]
}
// First create rows by fitting items naturally
let rows = createInitialRows(sizes: sizes, maxWidth: maxWidth)
// Then optimize distribution based on flow direction
return optimizeRowDistribution(rows: rows, sizes: sizes, maxWidth: maxWidth)
}
/// Create initial rows by fitting items sequentially
private func createInitialRows(
sizes: [CGSize],
maxWidth: CGFloat
) -> [[Int]] {
var rows: [[Int]] = []
var currentRow: [Int] = []
var currentWidth: CGFloat = 0
for (index, size) in sizes.enumerated() {
if currentRow.isEmpty {
currentRow.append(index)
currentWidth = size.width
} else {
let widthWithItem = currentWidth + spacing + size.width
if widthWithItem <= maxWidth {
currentRow.append(index)
currentWidth = widthWithItem
} else {
rows.append(currentRow)
currentRow = [index]
currentWidth = size.width
}
}
}
if !currentRow.isEmpty {
rows.append(currentRow)
}
return rows
}
/// Optimize row distribution based on flow direction
private func optimizeRowDistribution(
rows: [[Int]],
sizes: [CGSize],
maxWidth: CGFloat
) -> [[Int]] {
guard rows.count > 1 else { return rows }
var optimizedRows = rows
switch direction {
case .up:
// Move items from earlier rows to later rows to create upward flow
optimizedRows = balanceRowsForUpwardFlow(rows: optimizedRows, sizes: sizes, maxWidth: maxWidth)
case .down:
// Move items from later rows to earlier rows to create downward flow
optimizedRows = balanceRowsForDownwardFlow(rows: optimizedRows, sizes: sizes, maxWidth: maxWidth)
}
return optimizedRows
}
/// Balance rows for upward flow - fill bottom rows more than top rows
private func balanceRowsForUpwardFlow(
rows: [[Int]],
sizes: [CGSize],
maxWidth: CGFloat
) -> [[Int]] {
var optimizedRows = rows
for i in 0 ..< optimizedRows.count - 1 {
while optimizedRows[i].count > minRowLength {
let lastItem = optimizedRows[i].last!
var testRow = optimizedRows[i + 1]
testRow.append(lastItem)
let newWidth = computeRowWidth(indices: testRow, sizes: sizes)
if newWidth <= maxWidth {
optimizedRows[i].removeLast()
optimizedRows[i + 1].append(lastItem)
} else {
break
}
}
}
return optimizedRows
}
/// Balance rows for downward flow - fill top rows more than bottom rows
private func balanceRowsForDownwardFlow(
rows: [[Int]],
sizes: [CGSize],
maxWidth: CGFloat
) -> [[Int]] {
var optimizedRows = rows
for i in (0 ..< optimizedRows.count - 1).reversed() {
while optimizedRows[i + 1].count > minRowLength {
let firstItem = optimizedRows[i + 1].first!
var testRow = optimizedRows[i]
testRow.append(firstItem)
let newWidth = computeRowWidth(indices: testRow, sizes: sizes)
if newWidth <= maxWidth {
optimizedRows[i + 1].removeFirst()
optimizedRows[i].append(firstItem)
} else {
break
}
}
}
return optimizedRows
}
// MARK: - Compute Row Width
/// Calculates the total width needed for a row of items including spacing
private func computeRowWidth(
indices: [Int],
sizes: [CGSize]
) -> CGFloat {
guard indices.isNotEmpty else { return 0 }
let itemsWidth = indices.reduce(0) { $0 + sizes[$1].width }
let spacingWidth = spacing * CGFloat(indices.count - 1)
return itemsWidth + spacingWidth
}
// MARK: - Compute X Offset
/// Calculates the starting X position for a row based on the alignment setting
private func computeXOffset(
rowWidth: CGFloat,
bounds: CGRect
) -> CGFloat {
switch alignment {
case .trailing:
return bounds.maxX - rowWidth
case .center:
return bounds.minX + (bounds.width - rowWidth) / 2
default:
return bounds.minX
}
}
// MARK: - Compute Total Size
/// Calculates the total size needed to display all rows with proper spacing
private func computeTotalSize(
rows: [[Int]],
sizes: [CGSize]
) -> CGSize {
guard rows.isNotEmpty else { return .zero }
let rowHeights = rows.map { row in
row.map { sizes[$0].height }.max() ?? 0
}
let totalHeight = rowHeights.reduce(0, +) + lineSpacing * CGFloat(rows.count - 1)
let maxWidth = rows.map { row in
computeRowWidth(indices: row, sizes: sizes)
}.max() ?? 0
return CGSize(width: maxWidth, height: totalHeight)
}
}

View File

@ -1,131 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import SwiftUI
struct CountryPicker: View {
// MARK: - State Objects
@StateObject
private var viewModel: CountriesViewModel
// MARK: - Input Properties
private var selectionBinding: Binding<CountryInfo?>
private let title: String
@State
private var selection: CountryInfo?
// MARK: - Body
var body: some View {
Group {
#if os(tvOS)
ListRowMenu(title, subtitle: $selection.wrappedValue?.displayTitle) {
Picker(title, selection: $selection) {
Text(CountryInfo.none.displayTitle)
.tag(CountryInfo.none as CountryInfo?)
ForEach(viewModel.value, id: \.self) { country in
Text(country.displayTitle)
.tag(country as CountryInfo?)
}
}
}
// TODO: iOS 17+ move this to the Group
.onChange(of: viewModel.value) {
updateSelection()
}
.onChange(of: selection) { _, newValue in
selectionBinding.wrappedValue = newValue
}
.menuStyle(.borderlessButton)
.listRowInsets(.zero)
#else
Picker(title, selection: $selection) {
Text(CountryInfo.none.displayTitle)
.tag(CountryInfo.none as CountryInfo?)
ForEach(viewModel.value, id: \.self) { country in
Text(country.displayTitle)
.tag(country as CountryInfo?)
}
}
// TODO: iOS 17+ delete this and use the tvOS onChange at the Group level
.onChange(of: viewModel.value) { _ in
updateSelection()
}
.onChange(of: selection) { newValue in
selectionBinding.wrappedValue = newValue
}
#endif
}
.onFirstAppear {
viewModel.refresh()
}
}
private func updateSelection() {
let newValue = viewModel.value.first { value in
if let selectedTwo = selection?.twoLetterISORegionName,
let candidateTwo = value.twoLetterISORegionName,
selectedTwo == candidateTwo
{
return true
}
if let selectedThree = selection?.threeLetterISORegionName,
let candidateThree = value.threeLetterISORegionName,
selectedThree == candidateThree
{
return true
}
return false
}
selection = newValue ?? CountryInfo.none
}
}
extension CountryPicker {
init(_ title: String, twoLetterISORegion: Binding<String?>) {
self.title = title
self._selection = State(
initialValue: twoLetterISORegion.wrappedValue.flatMap { code in
CountryInfo(
name: code,
twoLetterISORegionName: code
)
} ?? CountryInfo.none
)
self.selectionBinding = Binding(
get: {
guard let code = twoLetterISORegion.wrappedValue else {
return CountryInfo.none
}
return CountryInfo(
name: code,
twoLetterISORegionName: code
)
},
set: { newCountry in
twoLetterISORegion.wrappedValue = newCountry?.twoLetterISORegionName
}
)
self._viewModel = StateObject(
wrappedValue: CountriesViewModel(
initialValue: [.none]
)
)
}
}

View File

@ -1,153 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import SwiftUI
struct CulturePicker: View {
// MARK: - State Objects
@StateObject
private var viewModel: CulturesViewModel
// MARK: - Input Properties
private var selectionBinding: Binding<CultureDto?>
private let title: String
@State
private var selection: CultureDto?
// MARK: - Body
var body: some View {
Group {
#if os(tvOS)
ListRowMenu(title, subtitle: $selection.wrappedValue?.displayTitle) {
Picker(title, selection: $selection) {
Text(CultureDto.none.displayTitle)
.tag(CultureDto.none as CultureDto?)
ForEach(viewModel.value, id: \.self) { value in
Text(value.displayTitle)
.tag(value as CultureDto?)
}
}
}
// TODO: iOS 17+ move this to the Group
.onChange(of: viewModel.value) {
updateSelection()
}
.onChange(of: selection) { _, newValue in
selectionBinding.wrappedValue = newValue
}
.menuStyle(.borderlessButton)
.listRowInsets(.zero)
#else
Picker(title, selection: $selection) {
Text(CultureDto.none.displayTitle)
.tag(CultureDto.none as CultureDto?)
ForEach(viewModel.value, id: \.self) { value in
Text(value.displayTitle)
.tag(value as CultureDto?)
}
}
// TODO: iOS 17+ delete this and use the tvOS onChange at the Group level
.onChange(of: viewModel.value) { _ in
updateSelection()
}
.onChange(of: selection) { newValue in
selectionBinding.wrappedValue = newValue
}
#endif
}
.onFirstAppear {
viewModel.refresh()
}
}
private func updateSelection() {
let newValue = viewModel.value.first { value in
if let selectedTwo = selection?.twoLetterISOLanguageName,
let candidateTwo = value.twoLetterISOLanguageName,
selectedTwo == candidateTwo
{
return true
}
if let selectedThree = selection?.threeLetterISOLanguageName,
let candidateThree = value.threeLetterISOLanguageName,
selectedThree == candidateThree
{
return true
}
return false
}
selection = newValue ?? CultureDto.none
}
}
extension CulturePicker {
init(_ title: String, twoLetterISOLanguageName: Binding<String?>) {
self.title = title
self._selection = State(
initialValue: twoLetterISOLanguageName.wrappedValue.flatMap {
CultureDto(twoLetterISOLanguageName: $0)
} ?? CultureDto.none
)
self.selectionBinding = Binding<CultureDto?>(
get: {
guard let code = twoLetterISOLanguageName.wrappedValue else {
return CultureDto.none
}
return CultureDto(twoLetterISOLanguageName: code)
},
set: { newCountry in
twoLetterISOLanguageName.wrappedValue = newCountry?.twoLetterISOLanguageName
}
)
self._viewModel = StateObject(
wrappedValue: CulturesViewModel(
initialValue: [.none]
)
)
}
init(_ title: String, threeLetterISOLanguageName: Binding<String?>) {
self.title = title
self._selection = State(
initialValue: threeLetterISOLanguageName.wrappedValue.flatMap {
CultureDto(threeLetterISOLanguageName: $0)
} ?? CultureDto.none
)
self.selectionBinding = Binding<CultureDto?>(
get: {
guard let code = threeLetterISOLanguageName.wrappedValue else {
return CultureDto.none
}
return CultureDto(threeLetterISOLanguageName: code)
},
set: { newCountry in
threeLetterISOLanguageName.wrappedValue = newCountry?.threeLetterISOLanguageName
}
)
self._viewModel = StateObject(
wrappedValue: CulturesViewModel(
initialValue: [.none]
)
)
}
}

View File

@ -1,120 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import SwiftUI
struct ParentalRatingPicker: View {
// MARK: - State Objects
@StateObject
private var viewModel: ParentalRatingsViewModel
// MARK: - Input Properties
private var selectionBinding: Binding<ParentalRating?>
private let title: String
@State
private var selection: ParentalRating?
// MARK: - Body
var body: some View {
Group {
#if os(tvOS)
ListRowMenu(title, subtitle: $selection.wrappedValue?.displayTitle) {
Picker(title, selection: $selection) {
Text(ParentalRating.none.displayTitle)
.tag(ParentalRating.none as ParentalRating?)
ForEach(viewModel.value, id: \.self) { value in
Text(value.displayTitle)
.tag(value as ParentalRating?)
}
}
}
.onChange(of: viewModel.value) {
updateSelection()
}
.onChange(of: selection) { _, newValue in
selectionBinding.wrappedValue = newValue
}
.menuStyle(.borderlessButton)
.listRowInsets(.zero)
#else
Picker(title, selection: $selection) {
Text(ParentalRating.none.displayTitle)
.tag(ParentalRating.none as ParentalRating?)
ForEach(viewModel.value, id: \.self) { value in
Text(value.displayTitle)
.tag(value as ParentalRating?)
}
}
.onChange(of: viewModel.value) { _ in
updateSelection()
}
.onChange(of: selection) { newValue in
selectionBinding.wrappedValue = newValue
}
#endif
}
.onFirstAppear {
viewModel.refresh()
}
}
// MARK: - Update Selection
private func updateSelection() {
let newValue = viewModel.value.first { value in
if let selectedName = selection?.name,
let candidateName = value.name,
selectedName == candidateName
{
return true
}
return false
}
selection = newValue ?? ParentalRating.none
}
}
extension ParentalRatingPicker {
init(_ title: String, name: Binding<String?>) {
self.title = title
self._selection = State(
initialValue: name.wrappedValue.flatMap {
ParentalRating(name: $0)
} ?? ParentalRating.none
)
self.selectionBinding = Binding<ParentalRating?>(
get: {
guard let ratingName = name.wrappedValue else {
return ParentalRating.none
}
return ParentalRating(name: ratingName)
},
set: { newRating in
name.wrappedValue = newRating?.name
}
)
self._viewModel = StateObject(
wrappedValue: ParentalRatingsViewModel(
initialValue: [.none]
)
)
}
}

View File

@ -1,91 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import SwiftUI
/// A `VStack` that displays subviews with a marker on the top leading edge.
///
/// In a marker view, ensure that views that are only used for layout are
/// tagged with `hidden` to avoid them being read by accessibility features.
struct MarkedList<Content: View, Marker: View>: View {
private let content: Content
private let marker: (Int) -> Marker
private let spacing: CGFloat
init(
spacing: CGFloat,
@ViewBuilder marker: @escaping (Int) -> Marker,
@ViewBuilder content: @escaping () -> Content
) {
self.marker = marker
self.content = content()
self.spacing = spacing
}
var body: some View {
_VariadicView.Tree(
MarkedListLayout(
spacing: spacing,
marker: marker
)
) {
content
}
}
}
extension MarkedList {
struct MarkedListLayout: _VariadicView_UnaryViewRoot {
let spacing: CGFloat
let marker: (Int) -> Marker
@ViewBuilder
func body(children: _VariadicView.Children) -> some View {
VStack(alignment: .leading, spacing: spacing) {
ForEach(Array(zip(children.indices, children)), id: \.0) { child in
MarkedListEntry(
marker: marker(child.0),
content: child.1
)
}
}
}
}
struct MarkedListEntry<EntryContent: View>: View {
@State
private var markerSize: CGSize = .zero
@State
private var childSize: CGSize = .zero
let marker: Marker
let content: EntryContent
private var _bullet: some View {
marker
.trackingSize($markerSize)
}
// TODO: this can cause clipping issues with text since
// with .offset, find fix
var body: some View {
ZStack {
content
.trackingSize($childSize)
.overlay(alignment: .topLeading) {
_bullet
.offset(x: -markerSize.width)
}
}
}
}
}

View File

@ -1,111 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import AVKit
import Factory
import JellyfinAPI
import Logging
import SwiftUI
// TODO: remove
struct NativeVideoPlayer: View {
@Environment(\.presentationCoordinator)
private var presentationCoordinator
@InjectedObject(\.mediaPlayerManager)
private var manager: MediaPlayerManager
@LazyState
private var proxy: AVMediaPlayerProxy
@Router
private var router
init() {
self._proxy = .init(wrappedValue: AVMediaPlayerProxy())
}
var body: some View {
ZStack {
Color.black
switch manager.state {
case .playback:
NativeVideoPlayerView(proxy: proxy)
default:
ProgressView()
}
}
.onAppear {
manager.proxy = proxy
manager.start()
}
.preference(key: IsStatusBarHiddenKey.self, value: true)
.backport
.onChange(of: presentationCoordinator.isPresented) { _, isPresented in
Container.shared.mediaPlayerManager.reset()
guard !isPresented else { return }
manager.stop()
}
.alert(
L10n.error,
isPresented: .constant(manager.error != nil)
) {
Button(L10n.close, role: .cancel) {
Container.shared.mediaPlayerManager.reset()
router.dismiss()
}
} message: {
// TODO: localize
Text("Unable to load this item.")
}
}
}
extension NativeVideoPlayer {
private struct NativeVideoPlayerView: UIViewControllerRepresentable {
let proxy: AVMediaPlayerProxy
func makeUIViewController(context: Context) -> UINativeVideoPlayerViewController {
UINativeVideoPlayerViewController(proxy: proxy)
}
func updateUIViewController(_ uiViewController: UINativeVideoPlayerViewController, context: Context) {}
}
private class UINativeVideoPlayerViewController: AVPlayerViewController {
private let proxy: AVMediaPlayerProxy
init(proxy: AVMediaPlayerProxy) {
self.proxy = proxy
super.init(nibName: nil, bundle: nil)
player = proxy.player
player?.allowsExternalPlayback = true
player?.appliesMediaSelectionCriteriaAutomatically = false
allowsPictureInPicturePlayback = true
#if !os(tvOS)
updatesNowPlayingInfoCenter = false
#endif
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
}

View File

@ -1,90 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import BlurHashKit
import SwiftUI
/// Retrieving images by exact pixel dimensions is a bit
/// intense for normal usage and eases cache usage and modifications.
private let landscapeMaxWidth: CGFloat = 300
private let portraitMaxWidth: CGFloat = 200
struct PosterImage<Item: Poster>: View {
private let contentMode: ContentMode
private let imageMaxWidth: CGFloat
private let item: Item
private let type: PosterDisplayType
init(
item: Item,
type: PosterDisplayType,
contentMode: ContentMode = .fill,
maxWidth: CGFloat? = nil
) {
self.contentMode = contentMode
self.imageMaxWidth = maxWidth ?? (type == .landscape ? landscapeMaxWidth : portraitMaxWidth)
self.item = item
self.type = type
}
private var imageSources: [ImageSource] {
switch type {
case .landscape:
item.landscapeImageSources(maxWidth: imageMaxWidth, quality: 90)
case .portrait:
item.portraitImageSources(maxWidth: imageMaxWidth, quality: 90)
case .square:
item.squareImageSources(maxWidth: imageMaxWidth, quality: 90)
}
}
var body: some View {
ZStack {
Rectangle()
.fill(.complexSecondary)
AlternateLayoutView {
Color.clear
} content: {
ImageView(imageSources)
.image(item.transform)
.placeholder { imageSource in
if let blurHash = imageSource.blurHash {
BlurHashView(blurHash: blurHash)
} else if item.showTitle {
SystemImageContentView(
systemName: item.systemImage
)
} else {
SystemImageContentView(
title: item.displayTitle,
systemName: item.systemImage
)
}
}
.failure {
if item.showTitle {
SystemImageContentView(
systemName: item.systemImage
)
} else {
SystemImageContentView(
title: item.displayTitle,
systemName: item.systemImage
)
}
}
}
}
.posterStyle(
type,
contentMode: contentMode
)
}
}

View File

@ -1,27 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import SwiftUI
struct TintedMaterial: UIViewRepresentable {
let tint: Color
func makeUIView(context: Context) -> UIVisualEffectView {
UIVisualEffectView(effect: UIBlurEffect(style: .extraLight))
}
func updateUIView(_ uiView: UIVisualEffectView, context: Context) {
set(tint: tint, for: uiView)
}
private func set(tint: Color, for view: UIVisualEffectView) {
let overlayView = view.subviews.first { type(of: $0) == NSClassFromString("_UIVisualEffectSubview") }
overlayView?.backgroundColor = UIColor(tint.opacity(0.75))
}
}

View File

@ -1,72 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import PulseUI
import Stinsen
import SwiftUI
final class AppSettingsCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \AppSettingsCoordinator.start)
@Root
var start = makeStart
#if os(iOS)
@Route(.push)
var about = makeAbout
@Route(.push)
var appIconSelector = makeAppIconSelector
@Route(.push)
var log = makeLog
#endif
#if os(tvOS)
@Route(.push)
var log = makeLog
@Route(.fullScreen)
var hourPicker = makeHourPicker
#endif
init() {}
#if os(iOS)
@ViewBuilder
func makeAbout(viewModel: SettingsViewModel) -> some View {
AboutAppView(viewModel: viewModel)
}
@ViewBuilder
func makeAppIconSelector(viewModel: SettingsViewModel) -> some View {
AppIconSelectorView(viewModel: viewModel)
}
#endif
@ViewBuilder
func makeLog() -> some View {
ConsoleView()
}
@ViewBuilder
func makeStart() -> some View {
AppSettingsView()
}
#if os(tvOS)
@ViewBuilder
func makeHourPicker() -> some View {
ZStack {
BlurView()
.ignoresSafeArea()
HourMinutePicker()
}
}
#endif
}

View File

@ -1,72 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import PulseUI
import Stinsen
import SwiftUI
final class AppSettingsCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \AppSettingsCoordinator.start)
@Root
var start = makeStart
#if os(iOS)
@Route(.push)
var about = makeAbout
@Route(.push)
var appIconSelector = makeAppIconSelector
@Route(.push)
var log = makeLog
#endif
#if os(tvOS)
@Route(.push)
var log = makeLog
@Route(.fullScreen)
var hourPicker = makeHourPicker
#endif
init() {}
#if os(iOS)
@ViewBuilder
func makeAbout(viewModel: SettingsViewModel) -> some View {
AboutAppView(viewModel: viewModel)
}
@ViewBuilder
func makeAppIconSelector(viewModel: SettingsViewModel) -> some View {
AppIconSelectorView(viewModel: viewModel)
}
#endif
@ViewBuilder
func makeLog() -> some View {
ConsoleView()
}
@ViewBuilder
func makeStart() -> some View {
AppSettingsView()
}
#if os(tvOS)
@ViewBuilder
func makeHourPicker() -> some View {
ZStack {
BlurView()
.ignoresSafeArea()
HourMinutePicker()
}
}
#endif
}

View File

@ -27,7 +27,7 @@ final class AppSettingsCoordinator: NavigationCoordinatable {
#endif #endif
#if os(tvOS) #if os(tvOS)
@Route(.push) @Route(.modal)
var log = makeLog var log = makeLog
@Route(.fullScreen) @Route(.fullScreen)

View File

@ -1,45 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Foundation
import JellyfinAPI
import Stinsen
import SwiftUI
final class HomeCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \HomeCoordinator.start)
@Root
var start = makeStart
#if os(tvOS)
@Route(.push)
var item = makeItem
@Route(.push)
var library = makeLibrary
#else
@Route(.push)
var item = makeItem
@Route(.push)
var library = makeLibrary
#endif
func makeItem(item: BaseItemDto) -> ItemCoordinator {
ItemCoordinator(item: item)
}
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
LibraryCoordinator(viewModel: viewModel)
}
@ViewBuilder
func makeStart() -> some View {
HomeView()
}
}

View File

@ -1,45 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Foundation
import JellyfinAPI
import Stinsen
import SwiftUI
final class HomeCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \HomeCoordinator.start)
@Root
var start = makeStart
#if os(tvOS)
@Route(.push)
var item = makeItem
@Route(.push)
var library = makeLibrary
#else
@Route(.push)
var item = makeItem
@Route(.push)
var library = makeLibrary
#endif
func makeItem(item: BaseItemDto) -> ItemCoordinator {
ItemCoordinator(item: item)
}
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
LibraryCoordinator(viewModel: viewModel)
}
@ViewBuilder
func makeStart() -> some View {
HomeView()
}
}

View File

@ -19,9 +19,9 @@ final class HomeCoordinator: NavigationCoordinatable {
var start = makeStart var start = makeStart
#if os(tvOS) #if os(tvOS)
@Route(.push) @Route(.modal)
var item = makeItem var item = makeItem
@Route(.push) @Route(.modal)
var library = makeLibrary var library = makeLibrary
#else #else
@Route(.push) @Route(.push)
@ -30,6 +30,15 @@ final class HomeCoordinator: NavigationCoordinatable {
var library = makeLibrary var library = makeLibrary
#endif #endif
#if os(tvOS)
func makeItem(item: BaseItemDto) -> NavigationViewCoordinator<ItemCoordinator> {
NavigationViewCoordinator(ItemCoordinator(item: item))
}
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> NavigationViewCoordinator<LibraryCoordinator<BaseItemDto>> {
NavigationViewCoordinator(LibraryCoordinator<BaseItemDto>(viewModel: viewModel))
}
#else
func makeItem(item: BaseItemDto) -> ItemCoordinator { func makeItem(item: BaseItemDto) -> ItemCoordinator {
ItemCoordinator(item: item) ItemCoordinator(item: item)
} }
@ -37,6 +46,7 @@ final class HomeCoordinator: NavigationCoordinatable {
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> { func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
LibraryCoordinator(viewModel: viewModel) LibraryCoordinator(viewModel: viewModel)
} }
#endif
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {

View File

@ -1,60 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Defaults
import Foundation
import JellyfinAPI
import Stinsen
import SwiftUI
final class LibraryCoordinator<Element: Poster>: NavigationCoordinatable {
let stack = NavigationStack(initial: \LibraryCoordinator.start)
@Root
var start = makeStart
#if os(tvOS)
@Route(.push)
var item = makeItem
@Route(.push)
var library = makeLibrary
#else
@Route(.push)
var item = makeItem
@Route(.push)
var library = makeLibrary
@Route(.modal)
var filter = makeFilter
#endif
private let viewModel: PagingLibraryViewModel<Element>
init(viewModel: PagingLibraryViewModel<Element>) {
self.viewModel = viewModel
}
@ViewBuilder
func makeStart() -> some View {
PagingLibraryView(viewModel: viewModel)
}
func makeItem(item: BaseItemDto) -> ItemCoordinator {
ItemCoordinator(item: item)
}
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
LibraryCoordinator<BaseItemDto>(viewModel: viewModel)
}
#if !os(tvOS)
func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator<FilterCoordinator> {
NavigationViewCoordinator(FilterCoordinator(parameters: parameters))
}
#endif
}

View File

@ -1,60 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Defaults
import Foundation
import JellyfinAPI
import Stinsen
import SwiftUI
final class LibraryCoordinator<Element: Poster>: NavigationCoordinatable {
let stack = NavigationStack(initial: \LibraryCoordinator.start)
@Root
var start = makeStart
#if os(tvOS)
@Route(.push)
var item = makeItem
@Route(.push)
var library = makeLibrary
#else
@Route(.push)
var item = makeItem
@Route(.push)
var library = makeLibrary
@Route(.modal)
var filter = makeFilter
#endif
private let viewModel: PagingLibraryViewModel<Element>
init(viewModel: PagingLibraryViewModel<Element>) {
self.viewModel = viewModel
}
@ViewBuilder
func makeStart() -> some View {
PagingLibraryView(viewModel: viewModel)
}
func makeItem(item: BaseItemDto) -> ItemCoordinator {
ItemCoordinator(item: item)
}
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
LibraryCoordinator<BaseItemDto>(viewModel: viewModel)
}
#if !os(tvOS)
func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator<FilterCoordinator> {
NavigationViewCoordinator(FilterCoordinator(parameters: parameters))
}
#endif
}

View File

@ -20,7 +20,7 @@ final class LibraryCoordinator<Element: Poster>: NavigationCoordinatable {
var start = makeStart var start = makeStart
#if os(tvOS) #if os(tvOS)
@Route(.push) @Route(.modal)
var item = makeItem var item = makeItem
@Route(.push) @Route(.push)
var library = makeLibrary var library = makeLibrary
@ -44,6 +44,15 @@ final class LibraryCoordinator<Element: Poster>: NavigationCoordinatable {
PagingLibraryView(viewModel: viewModel) PagingLibraryView(viewModel: viewModel)
} }
#if os(tvOS)
func makeItem(item: BaseItemDto) -> NavigationViewCoordinator<ItemCoordinator> {
NavigationViewCoordinator(ItemCoordinator(item: item))
}
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> NavigationViewCoordinator<LibraryCoordinator<BaseItemDto>> {
NavigationViewCoordinator(LibraryCoordinator<BaseItemDto>(viewModel: viewModel))
}
#else
func makeItem(item: BaseItemDto) -> ItemCoordinator { func makeItem(item: BaseItemDto) -> ItemCoordinator {
ItemCoordinator(item: item) ItemCoordinator(item: item)
} }
@ -52,7 +61,6 @@ final class LibraryCoordinator<Element: Poster>: NavigationCoordinatable {
LibraryCoordinator<BaseItemDto>(viewModel: viewModel) LibraryCoordinator<BaseItemDto>(viewModel: viewModel)
} }
#if !os(tvOS)
func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator<FilterCoordinator> { func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator<FilterCoordinator> {
NavigationViewCoordinator(FilterCoordinator(parameters: parameters)) NavigationViewCoordinator(FilterCoordinator(parameters: parameters))
} }

View File

@ -13,25 +13,24 @@ import SwiftUI
final class LiveTVCoordinator: TabCoordinatable { final class LiveTVCoordinator: TabCoordinatable {
var child = TabChild(startingItems: [ var child = TabChild(startingItems: [
\LiveTVCoordinator.programs,
\LiveTVCoordinator.channels, \LiveTVCoordinator.channels,
\LiveTVCoordinator.programGuide,
]) ])
@Route(tabItem: makeProgramGuideTab) @Route(tabItem: makeProgramsTab)
var programGuide = makeProgramGuide var programs = makePrograms
@Route(tabItem: makeChannelsTab) @Route(tabItem: makeChannelsTab)
var channels = makeChannels var channels = makeChannels
func makeProgramGuide() -> VideoPlayerWrapperCoordinator { func makePrograms() -> VideoPlayerWrapperCoordinator {
VideoPlayerWrapperCoordinator { VideoPlayerWrapperCoordinator {
ProgramGuideView() ProgramsView()
} }
} }
@ViewBuilder @ViewBuilder
func makeProgramGuideTab(isActive: Bool) -> some View { func makeProgramsTab(isActive: Bool) -> some View {
Label("Guide", systemImage: "list.bullet.rectangle") Label(L10n.programs, systemImage: "tv")
} }
func makeChannels() -> VideoPlayerWrapperCoordinator { func makeChannels() -> VideoPlayerWrapperCoordinator {

View File

@ -69,8 +69,6 @@ final class LiveVideoPlayerCoordinator: NavigationCoordinatable {
#else #else
PreferencesView { PreferencesView {
// Use VLC for Live TV to handle raw MPEG-TS streams from Dispatcharr
// (Native AVPlayer can't play raw MPEG-TS, only HLS)
if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin { if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin {
LiveVideoPlayer(manager: self.videoPlayerManager) LiveVideoPlayer(manager: self.videoPlayerManager)
} else { } else {

View File

@ -1,56 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Foundation
import JellyfinAPI
import Stinsen
import SwiftUI
final class MediaCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \MediaCoordinator.start)
@Root
var start = makeStart
#if os(tvOS)
@Route(.fullScreen)
var library = makeLibrary
@Route(.fullScreen)
var liveTV = makeLiveTV
#else
@Route(.push)
var library = makeLibrary
@Route(.push)
var liveTV = makeLiveTV
@Route(.push)
var downloads = makeDownloads
#endif
#if os(tvOS)
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> NavigationViewCoordinator<LibraryCoordinator<BaseItemDto>> {
NavigationViewCoordinator(LibraryCoordinator(viewModel: viewModel))
}
#else
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
LibraryCoordinator(viewModel: viewModel)
}
func makeDownloads() -> DownloadListCoordinator {
DownloadListCoordinator()
}
#endif
func makeLiveTV() -> LiveTVCoordinator {
LiveTVCoordinator()
}
@ViewBuilder
func makeStart() -> some View {
MediaView()
}
}

View File

@ -1,56 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Foundation
import JellyfinAPI
import Stinsen
import SwiftUI
final class MediaCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \MediaCoordinator.start)
@Root
var start = makeStart
#if os(tvOS)
@Route(.fullScreen)
var library = makeLibrary
@Route(.fullScreen)
var liveTV = makeLiveTV
#else
@Route(.push)
var library = makeLibrary
@Route(.push)
var liveTV = makeLiveTV
@Route(.push)
var downloads = makeDownloads
#endif
#if os(tvOS)
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> NavigationViewCoordinator<LibraryCoordinator<BaseItemDto>> {
NavigationViewCoordinator(LibraryCoordinator(viewModel: viewModel))
}
#else
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
LibraryCoordinator(viewModel: viewModel)
}
func makeDownloads() -> DownloadListCoordinator {
DownloadListCoordinator()
}
#endif
func makeLiveTV() -> LiveTVCoordinator {
LiveTVCoordinator()
}
@ViewBuilder
func makeStart() -> some View {
MediaView()
}
}

View File

@ -18,9 +18,9 @@ final class MediaCoordinator: NavigationCoordinatable {
@Root @Root
var start = makeStart var start = makeStart
#if os(tvOS) #if os(tvOS)
@Route(.fullScreen) @Route(.push)
var library = makeLibrary var library = makeLibrary
@Route(.fullScreen) @Route(.push)
var liveTV = makeLiveTV var liveTV = makeLiveTV
#else #else
@Route(.push) @Route(.push)

View File

@ -1,47 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import SwiftUI
@MainActor
final class NavigationCoordinator: ObservableObject {
@Published
var path: [NavigationRoute] = []
@Published
var presentedSheet: NavigationRoute?
@Published
var presentedFullScreen: NavigationRoute?
func push(
_ route: NavigationRoute
) {
let style = route.transitionStyle
#if os(tvOS)
switch style {
case .push, .sheet:
presentedSheet = route
case .fullscreen:
presentedFullScreen = route
}
#else
switch style {
case .push:
path.append(route)
case .sheet:
presentedSheet = route
case .fullscreen:
withAnimation {
presentedFullScreen = route
}
}
#endif
}
}

View File

@ -1,106 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import PreferencesView
import SwiftUI
import Transmission
// TODO: have full screen zoom presentation zoom from/to center
// - probably need to make mock view with matching ids
// TODO: have presentation dismissal be through preference keys
// - issue with all of the VC/view wrapping
extension EnvironmentValues {
@Entry
var presentationControllerShouldDismiss: Binding<Bool> = .constant(true)
}
struct NavigationInjectionView: View {
@StateObject
private var coordinator: NavigationCoordinator
@EnvironmentObject
private var rootCoordinator: RootCoordinator
@State
private var isPresentationInteractive: Bool = true
private let content: AnyView
init(
coordinator: @autoclosure @escaping () -> NavigationCoordinator,
@ViewBuilder content: @escaping () -> some View
) {
_coordinator = StateObject(wrappedValue: coordinator())
self.content = AnyView(content())
}
var body: some View {
NavigationStack(path: $coordinator.path) {
content
.navigationDestination(for: NavigationRoute.self) { route in
route.destination
}
}
.environment(
\.router,
.init(
navigationCoordinator: coordinator,
rootCoordinator: rootCoordinator
)
)
.sheet(
item: $coordinator.presentedSheet
) {
coordinator.presentedSheet = nil
} content: { route in
let newCoordinator = NavigationCoordinator()
NavigationInjectionView(coordinator: newCoordinator) {
route.destination
}
}
#if os(tvOS)
.fullScreenCover(
item: $coordinator.presentedFullScreen
) { route in
let newCoordinator = NavigationCoordinator()
NavigationInjectionView(coordinator: newCoordinator) {
route.destination
}
}
#else
.presentation(
$coordinator.presentedFullScreen,
transition: .zoomIfAvailable(
options: .init(
dimmingVisualEffect: .systemThickMaterialDark,
options: .init(
isInteractive: isPresentationInteractive
)
),
otherwise: .slide(.init(edge: .bottom), options: .init(isInteractive: isPresentationInteractive))
)
) { routeBinding, _ in
let vc = UIPreferencesHostingController {
NavigationInjectionView(coordinator: .init()) {
routeBinding.wrappedValue.destination
.environment(\.presentationControllerShouldDismiss, $isPresentationInteractive)
}
}
// TODO: presentation options for customizing background color, dimming effect, etc.
vc.view.backgroundColor = .black
return vc
}
#endif
}
}

View File

@ -1,226 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import SwiftUI
#if os(iOS)
extension NavigationRoute {
// MARK: - Active Sessions
static func activeDeviceDetails(box: BindingBox<SessionInfoDto?>) -> NavigationRoute {
NavigationRoute(id: "activeDeviceDetails") {
ActiveSessionDetailView(box: box)
}
}
static let activeSessions = NavigationRoute(
id: "activeSessions"
) {
ActiveSessionsView()
}
// MARK: - User Activity
static let activity = NavigationRoute(
id: "activity"
) {
ServerActivityView()
}
static func activityDetails(viewModel: ServerActivityDetailViewModel) -> NavigationRoute {
NavigationRoute(id: "activityDetails") {
ServerActivityDetailsView(viewModel: viewModel)
}
}
static func activityFilters(viewModel: ServerActivityViewModel) -> NavigationRoute {
NavigationRoute(
id: "activityFilters",
style: .sheet
) {
ServerActivityFilterView(viewModel: viewModel)
}
}
// MARK: - Server Tasks
static func addServerTaskTrigger(observer: ServerTaskObserver) -> NavigationRoute {
NavigationRoute(
id: "addServerTaskTrigger",
style: .sheet
) {
AddTaskTriggerView(observer: observer)
}
}
// MARK: - Users
static func addServerUser() -> NavigationRoute {
NavigationRoute(
id: "addServerUser",
style: .sheet
) {
AddServerUserView()
}
}
// MARK: - API Keys
static let apiKeys = NavigationRoute(
id: "apiKeys"
) {
APIKeysView()
}
// MARK: - Devices
static func deviceDetails(device: DeviceInfoDto, viewModel: DevicesViewModel) -> NavigationRoute {
NavigationRoute(id: "deviceDetails") {
DeviceDetailsView(device: device, viewModel: viewModel)
}
}
static let devices = NavigationRoute(
id: "devices"
) {
DevicesView()
}
// MARK: - Server Tasks
static func editServerTask(observer: ServerTaskObserver) -> NavigationRoute {
NavigationRoute(id: "editServerTask") {
EditServerTaskView(observer: observer)
}
}
// MARK: - Users
static func quickConnectAuthorize(user: UserDto) -> NavigationRoute {
NavigationRoute(id: "quickConnectAuthorize") {
QuickConnectAuthorizeView(user: user)
}
}
static func resetUserPasswordAdmin(userID: String) -> NavigationRoute {
NavigationRoute(
id: "resetUserPasswordAdmin",
style: .sheet
) {
ResetUserPasswordView(userID: userID, requiresCurrentPassword: false)
}
}
// MARK: - Server Logs
static let serverLogs = NavigationRoute(
id: "serverLogs"
) {
ServerLogsView()
}
// MARK: - Server Tasks
static let tasks = NavigationRoute(
id: "tasks"
) {
ServerTasksView()
}
// MARK: - Users
static func userAddAccessSchedule(viewModel: ServerUserAdminViewModel) -> NavigationRoute {
NavigationRoute(
id: "userAddAccessSchedule",
style: .sheet
) {
AddAccessScheduleView(viewModel: viewModel)
}
}
static func userAddAccessTag(viewModel: ServerUserAdminViewModel) -> NavigationRoute {
NavigationRoute(
id: "userAddAccessTag",
style: .sheet
) {
AddServerUserAccessTagsView(viewModel: viewModel)
}
}
static func userDetails(user: UserDto) -> NavigationRoute {
NavigationRoute(id: "userDetails") {
ServerUserDetailsView(user: user)
}
}
static func userDeviceAccess(viewModel: ServerUserAdminViewModel) -> NavigationRoute {
NavigationRoute(
id: "userDeviceAccess",
style: .sheet
) {
ServerUserDeviceAccessView(viewModel: viewModel)
}
}
static func userEditAccessSchedules(viewModel: ServerUserAdminViewModel) -> NavigationRoute {
NavigationRoute(id: "userEditAccessSchedules") {
EditAccessScheduleView(viewModel: viewModel)
}
}
static func userEditAccessTags(viewModel: ServerUserAdminViewModel) -> NavigationRoute {
NavigationRoute(id: "userEditAccessTags") {
EditServerUserAccessTagsView(viewModel: viewModel)
}
}
static func userLiveTVAccess(viewModel: ServerUserAdminViewModel) -> NavigationRoute {
NavigationRoute(
id: "userLiveTVAccess",
style: .sheet
) {
ServerUserLiveTVAccessView(viewModel: viewModel)
}
}
static func userMediaAccess(viewModel: ServerUserAdminViewModel) -> NavigationRoute {
NavigationRoute(
id: "userMediaAccess",
style: .sheet
) {
ServerUserMediaAccessView(viewModel: viewModel)
}
}
static func userParentalRatings(viewModel: ServerUserAdminViewModel) -> NavigationRoute {
NavigationRoute(
id: "userParentalRatings",
style: .sheet
) {
ServerUserParentalRatingView(viewModel: viewModel)
}
}
static func userPermissions(viewModel: ServerUserAdminViewModel) -> NavigationRoute {
NavigationRoute(
id: "userPermissions",
style: .sheet
) {
ServerUserPermissionsView(viewModel: viewModel)
}
}
static let users = NavigationRoute(
id: "users"
) {
ServerUsersView()
}
}
#endif

View File

@ -1,50 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import SwiftUI
extension NavigationRoute {
#if os(iOS)
static let aboutApp = NavigationRoute(
id: "about-app"
) {
AboutAppView()
}
static func appIconSelector(viewModel: SettingsViewModel) -> NavigationRoute {
NavigationRoute(
id: "app-icon-selector"
) {
AppIconSelectorView(viewModel: viewModel)
}
}
#endif
static let appSettings = NavigationRoute(
id: "app-settings",
style: .sheet
) {
AppSettingsView()
}
#if os(tvOS)
static let hourPicker = NavigationRoute(
id: "hour-picker",
style: .fullscreen
) {
ZStack {
BlurView()
.ignoresSafeArea()
HourMinutePicker()
}
}
#endif
}

View File

@ -1,34 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import SwiftUI
extension NavigationRoute {
static let downloadList = NavigationRoute(
id: "downloadList"
) {
#if os(iOS)
DownloadListView(viewModel: .init())
#else
EmptyView()
#endif
}
#if os(iOS)
static func downloadTask(downloadTask: DownloadTask) -> NavigationRoute {
NavigationRoute(
id: "downloadTask",
style: .sheet
) {
DownloadTaskView(downloadTask: downloadTask)
}
}
#endif
}

View File

@ -1,275 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import SwiftUI
extension NavigationRoute {
// MARK: - Item Editing
#if os(iOS)
static func addGenre(viewModel: GenreEditorViewModel) -> NavigationRoute {
NavigationRoute(
id: "addGenre",
style: .sheet
) {
AddItemElementView(viewModel: viewModel, type: .genres)
}
}
static func addItemImage(viewModel: ItemImagesViewModel, imageType: ImageType) -> NavigationRoute {
NavigationRoute(
id: "addItemImage",
style: .push(.automatic)
) {
AddItemImageView(
viewModel: viewModel,
imageType: imageType
)
}
}
static func addPeople(viewModel: PeopleEditorViewModel) -> NavigationRoute {
NavigationRoute(
id: "addPeople",
style: .sheet
) {
AddItemElementView(viewModel: viewModel, type: .people)
}
}
static func addStudio(viewModel: StudioEditorViewModel) -> NavigationRoute {
NavigationRoute(
id: "addStudio",
style: .sheet
) {
AddItemElementView(viewModel: viewModel, type: .studios)
}
}
static func addTag(viewModel: TagEditorViewModel) -> NavigationRoute {
NavigationRoute(
id: "addTag",
style: .sheet
) {
AddItemElementView(viewModel: viewModel, type: .tags)
}
}
#endif
static func castAndCrew(people: [BaseItemPerson], itemID: String?) -> NavigationRoute {
let id: String? = itemID == nil ? nil : "castAndCrew-\(itemID!)"
let viewModel = PagingLibraryViewModel(
title: L10n.castAndCrew,
id: id,
people
)
return NavigationRoute(id: "castAndCrew") {
PagingLibraryView(viewModel: viewModel)
}
}
#if os(iOS)
static func cropItemImage(viewModel: ItemImagesViewModel, image: UIImage, type: ImageType) -> NavigationRoute {
NavigationRoute(
id: "crop-Image"
) {
ItemPhotoCropView(
viewModel: viewModel,
image: image,
type: type
)
}
}
static func editGenres(item: BaseItemDto) -> NavigationRoute {
NavigationRoute(id: "editGenres") {
EditItemElementView<String>(
viewModel: GenreEditorViewModel(item: item),
type: .genres,
route: { router, viewModel in
router.route(to: .addGenre(viewModel: viewModel as! GenreEditorViewModel))
}
)
}
}
static func editSubtitles(item: BaseItemDto) -> NavigationRoute {
NavigationRoute(id: "editSubtitles") {
ItemSubtitlesView(item: item)
}
}
static func uploadSubtitle(viewModel: SubtitleEditorViewModel) -> NavigationRoute {
NavigationRoute(
id: "uploadSubtitle",
style: .sheet
) {
ItemSubtitleUploadView(viewModel: viewModel)
}
}
static func editMetadata(item: BaseItemDto) -> NavigationRoute {
NavigationRoute(
id: "editMetadata",
style: .sheet
) {
EditMetadataView(viewModel: ItemEditorViewModel(item: item))
}
}
static func editPeople(item: BaseItemDto) -> NavigationRoute {
NavigationRoute(id: "editPeople") {
EditItemElementView<BaseItemPerson>(
viewModel: PeopleEditorViewModel(item: item),
type: .people,
route: { router, viewModel in
router.route(to: .addPeople(viewModel: viewModel as! PeopleEditorViewModel))
}
)
}
}
static func editStudios(item: BaseItemDto) -> NavigationRoute {
NavigationRoute(id: "editStudios") {
EditItemElementView<NameGuidPair>(
viewModel: StudioEditorViewModel(item: item),
type: .studios,
route: { router, viewModel in
router.route(to: .addStudio(viewModel: viewModel as! StudioEditorViewModel))
}
)
}
}
static func editTags(item: BaseItemDto) -> NavigationRoute {
NavigationRoute(id: "editTags") {
EditItemElementView<String>(
viewModel: TagEditorViewModel(item: item),
type: .tags,
route: { router, viewModel in
router.route(to: .addTag(viewModel: viewModel as! TagEditorViewModel))
}
)
}
}
static func identifyItem(item: BaseItemDto) -> NavigationRoute {
NavigationRoute(id: "identifyItem") {
IdentifyItemView(item: item)
}
}
static func identifyItemResults(
viewModel: IdentifyItemViewModel,
result: RemoteSearchResult
) -> NavigationRoute {
NavigationRoute(
id: "identifyItemResults",
style: .sheet
) {
IdentifyItemView.RemoteSearchResultView(
viewModel: viewModel,
result: result
)
}
}
#endif
static func searchSubtitle(viewModel: SubtitleEditorViewModel) -> NavigationRoute {
NavigationRoute(
id: "searchSubtitle",
style: .sheet
) {
ItemSubtitleSearchView(viewModel: viewModel)
}
}
static func item(item: BaseItemDto) -> NavigationRoute {
NavigationRoute(
id: "item-\(item.id ?? "Unknown")",
withNamespace: { .push(.zoom(sourceID: "item", namespace: $0)) }
) {
ItemView(item: item)
}
}
#if os(iOS)
static func itemEditor(viewModel: ItemViewModel) -> NavigationRoute {
NavigationRoute(
id: "itemEditor",
style: .sheet
) {
ItemEditorView(viewModel: viewModel)
}
}
static func itemImageDetails(viewModel: ItemImagesViewModel, imageInfo: ImageInfo) -> NavigationRoute {
NavigationRoute(
id: "itemImageDetails",
style: .sheet
) {
ItemImageDetailsView(
viewModel: viewModel,
imageInfo: imageInfo
)
.isEditing(true)
}
}
static func itemImages(viewModel: ItemImagesViewModel) -> NavigationRoute {
NavigationRoute(
id: "itemImages",
style: .sheet
) {
ItemImagesView(viewModel: viewModel)
}
}
static func itemImageSelector(viewModel: ItemImagesViewModel, imageType: ImageType) -> NavigationRoute {
NavigationRoute(
id: "itemImageSelector",
style: .sheet
) {
ItemImagePicker(
viewModel: viewModel,
type: imageType
)
}
}
#endif
static func itemOverview(item: BaseItemDto) -> NavigationRoute {
NavigationRoute(
id: "itemOverview",
style: .sheet
) {
ItemOverviewView(item: item)
}
}
#if os(iOS)
static func itemSearchImageDetails(viewModel: ItemImagesViewModel, remoteImageInfo: RemoteImageInfo) -> NavigationRoute {
NavigationRoute(
id: "itemSearchImageDetails",
style: .sheet
) {
ItemImageDetailsView(
viewModel: viewModel,
remoteImageInfo: remoteImageInfo
)
.isEditing(false)
}
}
#endif
}

View File

@ -1,35 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import SwiftUI
extension NavigationRoute {
#if os(iOS)
static func filter(type: ItemFilterType, viewModel: FilterViewModel) -> NavigationRoute {
NavigationRoute(
id: "filter",
style: .sheet
) {
FilterView(viewModel: viewModel, type: type)
}
}
#endif
static func library(
viewModel: PagingLibraryViewModel<some Poster>
) -> NavigationRoute {
NavigationRoute(
id: "library-(\(viewModel.parent?.id ?? "Unparented"))",
withNamespace: { .push(.zoom(sourceID: "item", namespace: $0)) }
) {
PagingLibraryView(viewModel: viewModel)
}
}
}

View File

@ -1,121 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Defaults
import Factory
import JellyfinAPI
import PreferencesView
import SwiftUI
import Transmission
extension NavigationRoute {
static let channels = NavigationRoute(
id: "channels"
) {
ChannelLibraryView()
}
static let liveTV = NavigationRoute(
id: "liveTV"
) {
ProgramsView()
}
static func mediaSourceInfo(source: MediaSourceInfo) -> NavigationRoute {
NavigationRoute(
id: "mediaSourceInfo",
style: .sheet
) {
MediaSourceInfoView(source: source)
}
}
#if os(iOS)
static func mediaStreamInfo(mediaStream: MediaStream) -> NavigationRoute {
NavigationRoute(id: "mediaStreamInfo") {
MediaStreamInfoView(mediaStream: mediaStream)
}
}
#endif
@MainActor
static func videoPlayer(
item: BaseItemDto,
mediaSource: MediaSourceInfo? = nil,
queue: (any MediaPlayerQueue)? = nil
) -> NavigationRoute {
let provider = MediaPlayerItemProvider(item: item) { item in
try await MediaPlayerItem.build(for: item, mediaSource: mediaSource)
}
return Self.videoPlayer(provider: provider, queue: queue)
}
@MainActor
static func videoPlayer(
provider: MediaPlayerItemProvider,
queue: (any MediaPlayerQueue)? = nil
) -> NavigationRoute {
let manager = MediaPlayerManager(
item: provider.item,
queue: queue,
mediaPlayerItemProvider: provider.function
)
return Self.videoPlayer(manager: manager)
}
@MainActor
static func videoPlayer(manager: MediaPlayerManager) -> NavigationRoute {
Container.shared.mediaPlayerManager.register {
manager
}
Container.shared.mediaPlayerManagerPublisher()
.send(manager)
return NavigationRoute(
id: "videoPlayer",
style: .fullscreen
) {
VideoPlayerViewShim(manager: manager)
}
}
}
// TODO: shim until native vs swiftfin player is replace with vlc vs av layers
// - when removed, ensure same behavior with safe area
// - may just need to make a VC wrapper to capture them
struct VideoPlayerViewShim: View {
@State
private var safeAreaInsets: EdgeInsets = .init()
let manager: MediaPlayerManager
var body: some View {
Group {
if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin {
VideoPlayer()
} else {
NativeVideoPlayer()
}
}
.colorScheme(.dark) // use over `preferredColorScheme(.dark)` to not have destination change
.environment(\.safeAreaInsets, safeAreaInsets)
.supportedOrientations(.allButUpsideDown)
.ignoresSafeArea()
.persistentSystemOverlays(.hidden)
.toolbar(.hidden, for: .navigationBar)
.onSizeChanged { _, safeArea in
self.safeAreaInsets = safeArea.max(EdgeInsets.edgePadding)
}
}
}

View File

@ -1,205 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import PulseUI
import SwiftUI
extension NavigationRoute {
#if os(iOS)
static func actionButtonSelector(selectedButtonsBinding: Binding<[VideoPlayerActionButton]>) -> NavigationRoute {
NavigationRoute(id: "actionButtonSelector") {
ActionButtonSelectorView(selection: selectedButtonsBinding)
}
}
static let adminDashboard = NavigationRoute(
id: "adminDashboard"
) {
AdminDashboardView()
}
#endif
static let createCustomDeviceProfile = NavigationRoute(
id: "createCustomDeviceProfile",
style: .sheet
) {
CustomDeviceProfileSettingsView.EditCustomDeviceProfileView(profile: nil)
.navigationTitle(L10n.customProfile)
}
static let customDeviceProfileSettings = NavigationRoute(
id: "customDeviceProfileSettings"
) {
CustomDeviceProfileSettingsView()
}
static let customizeViewsSettings = NavigationRoute(
id: "customizeViewsSettings"
) {
CustomizeViewsSettings()
}
#if DEBUG && !os(tvOS)
static let debugSettings = NavigationRoute(
id: "debugSettings"
) {
DebugSettingsView()
}
#endif
static func editCustomDeviceProfile(profile: Binding<CustomDeviceProfile>) -> NavigationRoute {
NavigationRoute(
id: "editCustomDeviceProfile",
style: .sheet
) {
CustomDeviceProfileSettingsView.EditCustomDeviceProfileView(profile: profile)
.navigationTitle(L10n.customProfile)
}
}
static func editCustomDeviceProfileAudio(selection: Binding<[AudioCodec]>) -> NavigationRoute {
NavigationRoute(id: "editCustomDeviceProfileAudio") {
OrderedSectionSelectorView(selection: selection, sources: AudioCodec.allCases)
.navigationTitle(L10n.audio)
}
}
static func editCustomDeviceProfileContainer(selection: Binding<[MediaContainer]>) -> NavigationRoute {
NavigationRoute(id: "editCustomDeviceProfileContainer") {
OrderedSectionSelectorView(selection: selection, sources: MediaContainer.allCases)
.navigationTitle(L10n.containers)
}
}
static func editCustomDeviceProfileVideo(selection: Binding<[VideoCodec]>) -> NavigationRoute {
NavigationRoute(id: "editCustomDeviceProfileVideo") {
OrderedSectionSelectorView(selection: selection, sources: VideoCodec.allCases)
.navigationTitle(L10n.video)
}
}
static func editServer(server: ServerState, isEditing: Bool = false) -> NavigationRoute {
NavigationRoute(id: "editServer") {
EditServerView(server: server)
.isEditing(isEditing)
}
}
static let experimentalSettings = NavigationRoute(
id: "experimentalSettings"
) {
ExperimentalSettingsView()
}
static func fontPicker(selection: Binding<String>) -> NavigationRoute {
NavigationRoute(id: "fontPicker") {
FontPickerView(selection: selection)
}
}
#if os(iOS)
static let gestureSettings = NavigationRoute(
id: "gestureSettings"
) {
GestureSettingsView()
}
#endif
static let indicatorSettings = NavigationRoute(
id: "indicatorSettings"
) {
IndicatorSettingsView()
}
static func itemFilterDrawerSelector(selection: Binding<[ItemFilterType]>) -> NavigationRoute {
NavigationRoute(id: "itemFilterDrawerSelector") {
OrderedSectionSelectorView(selection: selection, sources: ItemFilterType.allCases)
.navigationTitle(L10n.filters)
}
}
static func itemOverviewView(item: BaseItemDto) -> NavigationRoute {
NavigationRoute(
id: "itemOverviewView",
style: .sheet
) {
ItemOverviewView(item: item)
}
}
static func itemViewAttributes(selection: Binding<[ItemViewAttribute]>) -> NavigationRoute {
NavigationRoute(id: "itemViewAttributes") {
OrderedSectionSelectorView(selection: selection, sources: ItemViewAttribute.allCases)
.navigationTitle(L10n.mediaAttributes.localizedCapitalized)
}
}
static let localSecurity = NavigationRoute(
id: "localSecurity"
) {
UserLocalSecurityView()
}
static let log = NavigationRoute(
id: "log"
) {
ConsoleView()
}
#if os(iOS)
static let nativePlayerSettings = NavigationRoute(
id: "nativePlayerSettings"
) {
NativeVideoPlayerSettingsView()
}
#endif
static let playbackQualitySettings = NavigationRoute(
id: "playbackQualitySettings"
) {
PlaybackQualitySettingsView()
}
#if os(iOS)
static func resetUserPassword(userID: String) -> NavigationRoute {
NavigationRoute(
id: "resetUserPassword",
style: .sheet
) {
ResetUserPasswordView(userID: userID, requiresCurrentPassword: true)
}
}
#endif
static func serverConnection(server: ServerState) -> NavigationRoute {
NavigationRoute(id: "serverConnection") {
EditServerView(server: server)
}
}
static let settings = NavigationRoute(
id: "settings",
style: .sheet
) {
SettingsView()
}
static func userProfile(viewModel: SettingsViewModel) -> NavigationRoute {
NavigationRoute(id: "userProfile") {
UserProfileSettingsView(viewModel: viewModel)
}
}
static let videoPlayerSettings = NavigationRoute(
id: "videoPlayerSettings"
) {
VideoPlayerSettingsView()
}
}

View File

@ -1,78 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import SwiftUI
extension NavigationRoute {
static let connectToServer = NavigationRoute(
id: "connectToServer",
style: .sheet
) {
ConnectToServerView()
}
static func quickConnect(quickConnect: QuickConnect) -> NavigationRoute {
NavigationRoute(
id: "quickConnectView",
style: .sheet
) {
QuickConnectView(quickConnect: quickConnect)
}
}
#if os(iOS)
static func userProfileImage(viewModel: UserProfileImageViewModel) -> NavigationRoute {
NavigationRoute(
id: "userProfileImage",
style: .sheet
) {
UserProfileImagePickerView(viewModel: viewModel)
}
}
static func userProfileImageCrop(viewModel: UserProfileImageViewModel, image: UIImage) -> NavigationRoute {
NavigationRoute(
id: "cropImage",
style: .sheet
) {
UserProfileImageCropView(
viewModel: viewModel,
image: image
)
}
}
// TODO: rename to `localUserAccessPolicy`
static func userSecurity(pinHint: Binding<String>, accessPolicy: Binding<UserAccessPolicy>) -> NavigationRoute {
NavigationRoute(
id: "userSecurity",
style: .sheet
) {
LocalUserAccessPolicyView(
pinHint: pinHint,
accessPolicy: accessPolicy
)
}
}
#endif
static func userSignIn(server: ServerState) -> NavigationRoute {
NavigationRoute(
id: "userSignIn",
style: .sheet
) {
WithUserAuthentication {
WithQuickConnect {
UserSignInView(server: server)
}
}
}
}
}

View File

@ -1,87 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Foundation
import SwiftUI
struct NavigationRoute: Identifiable, Hashable {
enum TransitionStyle: Hashable {
// TODO: sheet and fullscreen with `NavigationTransition`
case push(NavigationTransition)
case sheet
case fullscreen
}
enum TransitionType {
case automatic(TransitionStyle)
case withNamespace((Namespace.ID) -> TransitionStyle)
}
let id: String
private let content: AnyView
var transitionType: TransitionType
var namespace: Namespace.ID?
var transitionStyle: TransitionStyle {
switch transitionType {
case let .automatic(style):
return style
case let .withNamespace(builder):
if let namespace {
return builder(namespace)
} else {
return .push(.automatic)
}
}
}
init(
id: String,
style: TransitionStyle = .push(.automatic),
@ViewBuilder content: () -> some View
) {
self.id = id
self.transitionType = .automatic(style)
self.namespace = nil
self.content = AnyView(content())
}
init(
id: String,
withNamespace: @escaping (Namespace.ID) -> TransitionStyle,
@ViewBuilder content: () -> some View
) {
self.id = id
self.transitionType = .withNamespace(withNamespace)
self.namespace = nil
self.content = AnyView(content())
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.id == rhs.id
}
@ViewBuilder
var destination: some View {
if case let .push(style) = transitionStyle {
content
.backport
.navigationTransition(style)
} else {
content
}
}
}

View File

@ -1,101 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import SwiftUI
extension NavigationCoordinator {
@MainActor
struct Router {
let navigationCoordinator: NavigationCoordinator?
let rootCoordinator: RootCoordinator?
func route(
to route: NavigationRoute,
transition: NavigationRoute.TransitionType? = nil,
in namespace: Namespace.ID? = nil
) {
var route = route
route.namespace = namespace
route.transitionType = transition ?? route.transitionType
navigationCoordinator?.push(route)
}
func root(
_ root: RootItem
) {
rootCoordinator?.root(root)
}
}
}
@propertyWrapper
struct Router: DynamicProperty {
@MainActor
struct Wrapper {
let router: NavigationCoordinator.Router
let dismiss: DismissAction
func route(
to route: NavigationRoute,
in namespace: Namespace.ID? = nil
) {
router.route(
to: route,
transition: nil,
in: namespace
)
}
func route(
to route: NavigationRoute,
style: NavigationRoute.TransitionStyle,
in namespace: Namespace.ID? = nil
) {
router.route(
to: route,
transition: .automatic(style),
in: namespace
)
}
func route(
to route: NavigationRoute,
withNamespace: @escaping (Namespace.ID) -> NavigationRoute.TransitionStyle,
in namespace: Namespace.ID? = nil
) {
router.route(
to: route,
transition: .withNamespace(withNamespace),
in: namespace
)
}
}
// `.dismiss` causes changes on disappear
@Environment(\.self)
private var environment
var wrappedValue: Wrapper {
.init(
router: environment.router,
dismiss: environment.dismiss
)
}
}
extension EnvironmentValues {
@Entry
var router: NavigationCoordinator.Router = .init(
navigationCoordinator: nil,
rootCoordinator: nil
)
}

View File

@ -1,56 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
#if os(iOS)
import SwiftUI
import Transmission
// TODO: sometimes causes hangs?
struct WithTransitionReaderPublisher<Content: View>: View {
@StateObject
private var publishedBox: PublishedBox<LegacyEventPublisher<TransitionReaderProxy?>> = .init(initialValue: .init())
let content: Content
init(@ViewBuilder content: @escaping () -> Content) {
self.content = content()
}
var body: some View {
content
.environment(\.transitionReader, publishedBox.value)
.background {
TransitionReader { proxy in
Color.clear
.onChange(of: proxy) { newValue in
publishedBox.value.send(newValue)
}
}
}
}
}
@propertyWrapper
struct TransitionReaderObserver: DynamicProperty {
@Environment(\.transitionReader)
private var publisher
var wrappedValue: LegacyEventPublisher<TransitionReaderProxy?> {
publisher
}
}
extension EnvironmentValues {
@Entry
var transitionReader: LegacyEventPublisher<TransitionReaderProxy?> = .init()
}
#endif

View File

@ -1,86 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Defaults
import Factory
import Logging
import SwiftUI
@MainActor
final class RootCoordinator: ObservableObject {
@Published
var root: RootItem = .appLoading
private let logger = Logger.swiftfin()
init() {
Task {
do {
try await SwiftfinStore.setupDataStack()
if Container.shared.currentUserSession() != nil, !Defaults[.signOutOnClose] {
#if os(tvOS)
await MainActor.run {
root(.mainTab)
}
#else
await MainActor.run {
root(.serverCheck)
}
#endif
} else {
await MainActor.run {
root(.selectUser)
}
}
} catch {
await MainActor.run {
Notifications[.didFailMigration].post()
}
}
}
// Notification setup for state
Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn))
Notifications[.didSignOut].subscribe(self, selector: #selector(didSignOut))
Notifications[.didChangeCurrentServerURL].subscribe(self, selector: #selector(didChangeCurrentServerURL(_:)))
}
func root(_ newRoot: RootItem) {
root = newRoot
}
@objc
private func didSignIn() {
logger.info("Signed in")
#if os(tvOS)
root(.mainTab)
#else
root(.serverCheck)
#endif
}
@objc
private func didSignOut() {
logger.info("Signed out")
root(.selectUser)
}
@objc
func didChangeCurrentServerURL(_ notification: Notification) {
guard Container.shared.currentUserSession() != nil else { return }
Container.shared.currentUserSession.reset()
Notifications[.didSignIn].post()
}
}

View File

@ -1,48 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import SwiftUI
@MainActor
struct RootItem: Identifiable {
var id: String
let content: AnyView
init(
id: String,
@ViewBuilder content: () -> some View
) {
self.id = id
self.content = AnyView(content())
}
static let appLoading = RootItem(id: "appLoading") {
NavigationInjectionView(coordinator: .init()) {
AppLoadingView()
}
}
static let mainTab = RootItem(id: "mainTab") {
MainTabView()
}
static let selectUser = RootItem(id: "selectUser") {
NavigationInjectionView(coordinator: .init()) {
SelectUserView()
}
}
#if os(iOS)
static let serverCheck = RootItem(id: "serverCheck") {
NavigationInjectionView(coordinator: .init()) {
ServerCheckView()
}
}
#endif
}

View File

@ -1,48 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import SwiftUI
import Transmission
// Status bar presentation needs to happen at this level
struct RootView: View {
@State
private var isStatusBarHidden: Bool = false
@StateObject
private var rootCoordinator: RootCoordinator = .init()
var body: some View {
ZStack {
if rootCoordinator.root.id == RootItem.appLoading.id {
RootItem.appLoading.content
}
if rootCoordinator.root.id == RootItem.mainTab.id {
RootItem.mainTab.content
}
if rootCoordinator.root.id == RootItem.selectUser.id {
RootItem.selectUser.content
}
#if os(iOS)
if rootCoordinator.root.id == RootItem.serverCheck.id {
RootItem.serverCheck.content
}
#endif
}
.animation(.linear(duration: 0.1), value: rootCoordinator.root.id)
.environmentObject(rootCoordinator)
.prefersStatusBarHidden(isStatusBarHidden)
.onPreferenceChange(IsStatusBarHiddenKey.self) { newValue in
isStatusBarHidden = newValue
}
}
}

View File

@ -1,52 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Foundation
import JellyfinAPI
import Stinsen
import SwiftUI
final class SearchCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \SearchCoordinator.start)
@Root
var start = makeStart
#if os(tvOS)
@Route(.push)
var item = makeItem
@Route(.push)
var library = makeLibrary
#else
@Route(.push)
var item = makeItem
@Route(.push)
var library = makeLibrary
@Route(.modal)
var filter = makeFilter
#endif
func makeItem(item: BaseItemDto) -> ItemCoordinator {
ItemCoordinator(item: item)
}
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
LibraryCoordinator(viewModel: viewModel)
}
#if !os(tvOS)
func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator<FilterCoordinator> {
NavigationViewCoordinator(FilterCoordinator(parameters: parameters))
}
#endif
@ViewBuilder
func makeStart() -> some View {
SearchView()
}
}

View File

@ -1,52 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Foundation
import JellyfinAPI
import Stinsen
import SwiftUI
final class SearchCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \SearchCoordinator.start)
@Root
var start = makeStart
#if os(tvOS)
@Route(.push)
var item = makeItem
@Route(.push)
var library = makeLibrary
#else
@Route(.push)
var item = makeItem
@Route(.push)
var library = makeLibrary
@Route(.modal)
var filter = makeFilter
#endif
func makeItem(item: BaseItemDto) -> ItemCoordinator {
ItemCoordinator(item: item)
}
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
LibraryCoordinator(viewModel: viewModel)
}
#if !os(tvOS)
func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator<FilterCoordinator> {
NavigationViewCoordinator(FilterCoordinator(parameters: parameters))
}
#endif
@ViewBuilder
func makeStart() -> some View {
SearchView()
}
}

View File

@ -18,9 +18,9 @@ final class SearchCoordinator: NavigationCoordinatable {
@Root @Root
var start = makeStart var start = makeStart
#if os(tvOS) #if os(tvOS)
@Route(.push) @Route(.modal)
var item = makeItem var item = makeItem
@Route(.push) @Route(.modal)
var library = makeLibrary var library = makeLibrary
#else #else
@Route(.push) @Route(.push)
@ -31,6 +31,15 @@ final class SearchCoordinator: NavigationCoordinatable {
var filter = makeFilter var filter = makeFilter
#endif #endif
#if os(tvOS)
func makeItem(item: BaseItemDto) -> NavigationViewCoordinator<ItemCoordinator> {
NavigationViewCoordinator(ItemCoordinator(item: item))
}
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> NavigationViewCoordinator<LibraryCoordinator<BaseItemDto>> {
NavigationViewCoordinator(LibraryCoordinator(viewModel: viewModel))
}
#else
func makeItem(item: BaseItemDto) -> ItemCoordinator { func makeItem(item: BaseItemDto) -> ItemCoordinator {
ItemCoordinator(item: item) ItemCoordinator(item: item)
} }
@ -39,7 +48,6 @@ final class SearchCoordinator: NavigationCoordinatable {
LibraryCoordinator(viewModel: viewModel) LibraryCoordinator(viewModel: viewModel)
} }
#if !os(tvOS)
func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator<FilterCoordinator> { func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator<FilterCoordinator> {
NavigationViewCoordinator(FilterCoordinator(parameters: parameters)) NavigationViewCoordinator(FilterCoordinator(parameters: parameters))
} }

View File

@ -1,59 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Foundation
import Stinsen
import SwiftUI
final class SelectUserCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \SelectUserCoordinator.start)
@Root
var start = makeStart
@Route(.push)
var advancedSettings = makeAdvancedSettings
@Route(.push)
var connectToServer = makeConnectToServer
@Route(.push)
var editServer = makeEditServer
@Route(.push)
var userSignIn = makeUserSignIn
func makeAdvancedSettings() -> NavigationViewCoordinator<AppSettingsCoordinator> {
NavigationViewCoordinator(AppSettingsCoordinator())
}
func makeConnectToServer() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
ConnectToServerView()
}
}
func makeEditServer(server: ServerState) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
EditServerView(server: server)
.environment(\.isEditing, true)
#if os(iOS)
.navigationBarCloseButton {
self.popLast()
}
#endif
}
}
func makeUserSignIn(server: ServerState) -> NavigationViewCoordinator<UserSignInCoordinator> {
NavigationViewCoordinator(UserSignInCoordinator(server: server))
}
@ViewBuilder
func makeStart() -> some View {
SelectUserView()
}
}

View File

@ -1,59 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Foundation
import Stinsen
import SwiftUI
final class SelectUserCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \SelectUserCoordinator.start)
@Root
var start = makeStart
@Route(.push)
var advancedSettings = makeAdvancedSettings
@Route(.push)
var connectToServer = makeConnectToServer
@Route(.push)
var editServer = makeEditServer
@Route(.push)
var userSignIn = makeUserSignIn
func makeAdvancedSettings() -> NavigationViewCoordinator<AppSettingsCoordinator> {
NavigationViewCoordinator(AppSettingsCoordinator())
}
func makeConnectToServer() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
ConnectToServerView()
}
}
func makeEditServer(server: ServerState) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
EditServerView(server: server)
.environment(\.isEditing, true)
#if os(iOS)
.navigationBarCloseButton {
self.popLast()
}
#endif
}
}
func makeUserSignIn(server: ServerState) -> NavigationViewCoordinator<UserSignInCoordinator> {
NavigationViewCoordinator(UserSignInCoordinator(server: server))
}
@ViewBuilder
func makeStart() -> some View {
SelectUserView()
}
}

View File

@ -17,36 +17,23 @@ final class SelectUserCoordinator: NavigationCoordinatable {
@Root @Root
var start = makeStart var start = makeStart
@Route(.push) @Route(.modal)
var advancedSettings = makeAdvancedSettings var advancedSettings = makeAdvancedSettings
@Route(.push) @Route(.modal)
var connectToServer = makeConnectToServer var connectToServer = makeConnectToServer
@Route(.push) @Route(.modal)
var connectToXtream = makeConnectToXtream
@Route(.push)
var dualServerConnect = makeDualServerConnect
@Route(.push)
var editServer = makeEditServer var editServer = makeEditServer
@Route(.push) @Route(.modal)
var userSignIn = makeUserSignIn var userSignIn = makeUserSignIn
func makeAdvancedSettings() -> NavigationViewCoordinator<AppSettingsCoordinator> { func makeAdvancedSettings() -> NavigationViewCoordinator<AppSettingsCoordinator> {
NavigationViewCoordinator(AppSettingsCoordinator()) NavigationViewCoordinator(AppSettingsCoordinator())
} }
@ViewBuilder func makeConnectToServer() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
func makeConnectToServer() -> some View { NavigationViewCoordinator {
ConnectToServerView() ConnectToServerView()
} }
@ViewBuilder
func makeConnectToXtream() -> some View {
ConnectToXtreamView()
}
@ViewBuilder
func makeDualServerConnect() -> some View {
DualServerConnectView()
} }
func makeEditServer(server: ServerState) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> { func makeEditServer(server: ServerState) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {

View File

@ -1,263 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import PulseUI
import Stinsen
import SwiftUI
final class SettingsCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \SettingsCoordinator.start)
@Root
var start = makeStart
#if os(iOS)
@Route(.push)
var log = makeLog
@Route(.push)
var nativePlayerSettings = makeNativePlayerSettings
@Route(.push)
var playbackQualitySettings = makePlaybackQualitySettings
@Route(.push)
var quickConnect = makeQuickConnectAuthorize
@Route(.push)
var resetUserPassword = makeResetUserPassword
@Route(.push)
var localSecurity = makeLocalSecurity
@Route(.push)
var photoPicker = makePhotoPicker
@Route(.push)
var userProfile = makeUserProfileSettings
@Route(.push)
var customizeViewsSettings = makeCustomizeViewsSettings
@Route(.push)
var experimentalSettings = makeExperimentalSettings
@Route(.push)
var itemFilterDrawerSelector = makeItemFilterDrawerSelector
@Route(.push)
var indicatorSettings = makeIndicatorSettings
@Route(.push)
var itemViewAttributes = makeItemViewAttributes
@Route(.push)
var serverConnection = makeServerConnection
@Route(.push)
var videoPlayerSettings = makeVideoPlayerSettings
@Route(.push)
var customDeviceProfileSettings = makeCustomDeviceProfileSettings
@Route(.push)
var itemOverviewView = makeItemOverviewView
@Route(.push)
var editCustomDeviceProfile = makeEditCustomDeviceProfile
@Route(.push)
var createCustomDeviceProfile = makeCreateCustomDeviceProfile
@Route(.push)
var adminDashboard = makeAdminDashboard
#if DEBUG
@Route(.push)
var debugSettings = makeDebugSettings
#endif
#endif
#if os(tvOS)
@Route(.push)
var customizeViewsSettings = makeCustomizeViewsSettings
@Route(.push)
var experimentalSettings = makeExperimentalSettings
@Route(.push)
var log = makeLog
@Route(.push)
var serverDetail = makeServerDetail
@Route(.push)
var videoPlayerSettings = makeVideoPlayerSettings
@Route(.push)
var playbackQualitySettings = makePlaybackQualitySettings
@Route(.push)
var userProfile = makeUserProfileSettings
#endif
#if os(iOS)
@ViewBuilder
func makeNativePlayerSettings() -> some View {
NativeVideoPlayerSettingsView()
}
@ViewBuilder
func makePlaybackQualitySettings() -> some View {
PlaybackQualitySettingsView()
}
@ViewBuilder
func makeCustomDeviceProfileSettings() -> some View {
CustomDeviceProfileSettingsView()
}
func makeEditCustomDeviceProfile(profile: Binding<CustomDeviceProfile>)
-> NavigationViewCoordinator<EditCustomDeviceProfileCoordinator>
{
NavigationViewCoordinator(EditCustomDeviceProfileCoordinator(profile: profile))
}
func makeCreateCustomDeviceProfile() -> NavigationViewCoordinator<EditCustomDeviceProfileCoordinator> {
NavigationViewCoordinator(EditCustomDeviceProfileCoordinator())
}
@ViewBuilder
func makeQuickConnectAuthorize(user: UserDto) -> some View {
QuickConnectAuthorizeView(user: user)
}
func makeResetUserPassword(userID: String) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
ResetUserPasswordView(userID: userID, requiresCurrentPassword: true)
}
}
@ViewBuilder
func makeLocalSecurity() -> some View {
UserLocalSecurityView()
}
func makePhotoPicker(viewModel: UserProfileImageViewModel) -> NavigationViewCoordinator<UserProfileImageCoordinator> {
NavigationViewCoordinator(UserProfileImageCoordinator(viewModel: viewModel))
}
@ViewBuilder
func makeUserProfileSettings(viewModel: SettingsViewModel) -> some View {
UserProfileSettingsView(viewModel: viewModel)
}
@ViewBuilder
func makeCustomizeViewsSettings() -> some View {
CustomizeViewsSettings()
}
@ViewBuilder
func makeExperimentalSettings() -> some View {
ExperimentalSettingsView()
}
@ViewBuilder
func makeIndicatorSettings() -> some View {
IndicatorSettingsView()
}
@ViewBuilder
func makeItemViewAttributes(selection: Binding<[ItemViewAttribute]>) -> some View {
OrderedSectionSelectorView(selection: selection, sources: ItemViewAttribute.allCases)
.navigationTitle(L10n.mediaAttributes.localizedCapitalized)
}
@ViewBuilder
func makeServerConnection(server: ServerState) -> some View {
EditServerView(server: server)
}
func makeItemOverviewView(item: BaseItemDto) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
ItemOverviewView(item: item)
}
}
func makeItemFilterDrawerSelector(selection: Binding<[ItemFilterType]>) -> some View {
OrderedSectionSelectorView(selection: selection, sources: ItemFilterType.allCases)
.navigationTitle(L10n.filters)
}
func makeVideoPlayerSettings() -> VideoPlayerSettingsCoordinator {
VideoPlayerSettingsCoordinator()
}
@ViewBuilder
func makeAdminDashboard() -> some View {
AdminDashboardCoordinator().view()
}
#if DEBUG
@ViewBuilder
func makeDebugSettings() -> some View {
DebugSettingsView()
}
#endif
#endif
#if os(tvOS)
// MARK: - User Profile View
func makeUserProfileSettings(viewModel: SettingsViewModel) -> NavigationViewCoordinator<UserProfileSettingsCoordinator> {
NavigationViewCoordinator(
UserProfileSettingsCoordinator(viewModel: viewModel)
)
}
// MARK: - Customize Settings View
func makeCustomizeViewsSettings() -> NavigationViewCoordinator<CustomizeSettingsCoordinator> {
NavigationViewCoordinator(
CustomizeSettingsCoordinator()
)
}
// MARK: - Experimental Settings View
func makeExperimentalSettings() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator(
BasicNavigationViewCoordinator {
ExperimentalSettingsView()
}
)
}
// MARK: - Poster Indicator Settings View
func makeIndicatorSettings() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
IndicatorSettingsView()
}
}
// MARK: - Server Settings View
func makeServerDetail(server: ServerState) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
EditServerView(server: server)
}
}
// MARK: - Video Player Settings View
func makeVideoPlayerSettings() -> NavigationViewCoordinator<VideoPlayerSettingsCoordinator> {
NavigationViewCoordinator(
VideoPlayerSettingsCoordinator()
)
}
// MARK: - Playback Settings View
func makePlaybackQualitySettings() -> NavigationViewCoordinator<PlaybackQualitySettingsCoordinator> {
NavigationViewCoordinator(
PlaybackQualitySettingsCoordinator()
)
}
#endif
@ViewBuilder
func makeLog() -> some View {
ConsoleView()
}
@ViewBuilder
func makeStart() -> some View {
SettingsView()
}
}

View File

@ -1,263 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import PulseUI
import Stinsen
import SwiftUI
final class SettingsCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \SettingsCoordinator.start)
@Root
var start = makeStart
#if os(iOS)
@Route(.push)
var log = makeLog
@Route(.push)
var nativePlayerSettings = makeNativePlayerSettings
@Route(.push)
var playbackQualitySettings = makePlaybackQualitySettings
@Route(.push)
var quickConnect = makeQuickConnectAuthorize
@Route(.push)
var resetUserPassword = makeResetUserPassword
@Route(.push)
var localSecurity = makeLocalSecurity
@Route(.push)
var photoPicker = makePhotoPicker
@Route(.push)
var userProfile = makeUserProfileSettings
@Route(.push)
var customizeViewsSettings = makeCustomizeViewsSettings
@Route(.push)
var experimentalSettings = makeExperimentalSettings
@Route(.push)
var itemFilterDrawerSelector = makeItemFilterDrawerSelector
@Route(.push)
var indicatorSettings = makeIndicatorSettings
@Route(.push)
var itemViewAttributes = makeItemViewAttributes
@Route(.push)
var serverConnection = makeServerConnection
@Route(.push)
var videoPlayerSettings = makeVideoPlayerSettings
@Route(.push)
var customDeviceProfileSettings = makeCustomDeviceProfileSettings
@Route(.push)
var itemOverviewView = makeItemOverviewView
@Route(.push)
var editCustomDeviceProfile = makeEditCustomDeviceProfile
@Route(.push)
var createCustomDeviceProfile = makeCreateCustomDeviceProfile
@Route(.push)
var adminDashboard = makeAdminDashboard
#if DEBUG
@Route(.push)
var debugSettings = makeDebugSettings
#endif
#endif
#if os(tvOS)
@Route(.push)
var customizeViewsSettings = makeCustomizeViewsSettings
@Route(.push)
var experimentalSettings = makeExperimentalSettings
@Route(.push)
var log = makeLog
@Route(.push)
var serverDetail = makeServerDetail
@Route(.push)
var videoPlayerSettings = makeVideoPlayerSettings
@Route(.push)
var playbackQualitySettings = makePlaybackQualitySettings
@Route(.push)
var userProfile = makeUserProfileSettings
#endif
#if os(iOS)
@ViewBuilder
func makeNativePlayerSettings() -> some View {
NativeVideoPlayerSettingsView()
}
@ViewBuilder
func makePlaybackQualitySettings() -> some View {
PlaybackQualitySettingsView()
}
@ViewBuilder
func makeCustomDeviceProfileSettings() -> some View {
CustomDeviceProfileSettingsView()
}
func makeEditCustomDeviceProfile(profile: Binding<CustomDeviceProfile>)
-> NavigationViewCoordinator<EditCustomDeviceProfileCoordinator>
{
NavigationViewCoordinator(EditCustomDeviceProfileCoordinator(profile: profile))
}
func makeCreateCustomDeviceProfile() -> NavigationViewCoordinator<EditCustomDeviceProfileCoordinator> {
NavigationViewCoordinator(EditCustomDeviceProfileCoordinator())
}
@ViewBuilder
func makeQuickConnectAuthorize(user: UserDto) -> some View {
QuickConnectAuthorizeView(user: user)
}
func makeResetUserPassword(userID: String) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
ResetUserPasswordView(userID: userID, requiresCurrentPassword: true)
}
}
@ViewBuilder
func makeLocalSecurity() -> some View {
UserLocalSecurityView()
}
func makePhotoPicker(viewModel: UserProfileImageViewModel) -> NavigationViewCoordinator<UserProfileImageCoordinator> {
NavigationViewCoordinator(UserProfileImageCoordinator(viewModel: viewModel))
}
@ViewBuilder
func makeUserProfileSettings(viewModel: SettingsViewModel) -> some View {
UserProfileSettingsView(viewModel: viewModel)
}
@ViewBuilder
func makeCustomizeViewsSettings() -> some View {
CustomizeViewsSettings()
}
@ViewBuilder
func makeExperimentalSettings() -> some View {
ExperimentalSettingsView()
}
@ViewBuilder
func makeIndicatorSettings() -> some View {
IndicatorSettingsView()
}
@ViewBuilder
func makeItemViewAttributes(selection: Binding<[ItemViewAttribute]>) -> some View {
OrderedSectionSelectorView(selection: selection, sources: ItemViewAttribute.allCases)
.navigationTitle(L10n.mediaAttributes.localizedCapitalized)
}
@ViewBuilder
func makeServerConnection(server: ServerState) -> some View {
EditServerView(server: server)
}
func makeItemOverviewView(item: BaseItemDto) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
ItemOverviewView(item: item)
}
}
func makeItemFilterDrawerSelector(selection: Binding<[ItemFilterType]>) -> some View {
OrderedSectionSelectorView(selection: selection, sources: ItemFilterType.allCases)
.navigationTitle(L10n.filters)
}
func makeVideoPlayerSettings() -> VideoPlayerSettingsCoordinator {
VideoPlayerSettingsCoordinator()
}
@ViewBuilder
func makeAdminDashboard() -> some View {
AdminDashboardCoordinator().view()
}
#if DEBUG
@ViewBuilder
func makeDebugSettings() -> some View {
DebugSettingsView()
}
#endif
#endif
#if os(tvOS)
// MARK: - User Profile View
func makeUserProfileSettings(viewModel: SettingsViewModel) -> NavigationViewCoordinator<UserProfileSettingsCoordinator> {
NavigationViewCoordinator(
UserProfileSettingsCoordinator(viewModel: viewModel)
)
}
// MARK: - Customize Settings View
func makeCustomizeViewsSettings() -> NavigationViewCoordinator<CustomizeSettingsCoordinator> {
NavigationViewCoordinator(
CustomizeSettingsCoordinator()
)
}
// MARK: - Experimental Settings View
func makeExperimentalSettings() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator(
BasicNavigationViewCoordinator {
ExperimentalSettingsView()
}
)
}
// MARK: - Poster Indicator Settings View
func makeIndicatorSettings() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
IndicatorSettingsView()
}
}
// MARK: - Server Settings View
func makeServerDetail(server: ServerState) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
EditServerView(server: server)
}
}
// MARK: - Video Player Settings View
func makeVideoPlayerSettings() -> NavigationViewCoordinator<VideoPlayerSettingsCoordinator> {
NavigationViewCoordinator(
VideoPlayerSettingsCoordinator()
)
}
// MARK: - Playback Settings View
func makePlaybackQualitySettings() -> NavigationViewCoordinator<PlaybackQualitySettingsCoordinator> {
NavigationViewCoordinator(
PlaybackQualitySettingsCoordinator()
)
}
#endif
@ViewBuilder
func makeLog() -> some View {
ConsoleView()
}
@ViewBuilder
func makeStart() -> some View {
SettingsView()
}
}

View File

@ -26,11 +26,11 @@ final class SettingsCoordinator: NavigationCoordinatable {
var playbackQualitySettings = makePlaybackQualitySettings var playbackQualitySettings = makePlaybackQualitySettings
@Route(.push) @Route(.push)
var quickConnect = makeQuickConnectAuthorize var quickConnect = makeQuickConnectAuthorize
@Route(.push) @Route(.modal)
var resetUserPassword = makeResetUserPassword var resetUserPassword = makeResetUserPassword
@Route(.push) @Route(.push)
var localSecurity = makeLocalSecurity var localSecurity = makeLocalSecurity
@Route(.push) @Route(.modal)
var photoPicker = makePhotoPicker var photoPicker = makePhotoPicker
@Route(.push) @Route(.push)
var userProfile = makeUserProfileSettings var userProfile = makeUserProfileSettings
@ -51,12 +51,12 @@ final class SettingsCoordinator: NavigationCoordinatable {
var videoPlayerSettings = makeVideoPlayerSettings var videoPlayerSettings = makeVideoPlayerSettings
@Route(.push) @Route(.push)
var customDeviceProfileSettings = makeCustomDeviceProfileSettings var customDeviceProfileSettings = makeCustomDeviceProfileSettings
@Route(.push) @Route(.modal)
var itemOverviewView = makeItemOverviewView var itemOverviewView = makeItemOverviewView
@Route(.push) @Route(.modal)
var editCustomDeviceProfile = makeEditCustomDeviceProfile var editCustomDeviceProfile = makeEditCustomDeviceProfile
@Route(.push) @Route(.modal)
var createCustomDeviceProfile = makeCreateCustomDeviceProfile var createCustomDeviceProfile = makeCreateCustomDeviceProfile
@Route(.push) @Route(.push)
@ -69,19 +69,19 @@ final class SettingsCoordinator: NavigationCoordinatable {
#endif #endif
#if os(tvOS) #if os(tvOS)
@Route(.push) @Route(.modal)
var customizeViewsSettings = makeCustomizeViewsSettings var customizeViewsSettings = makeCustomizeViewsSettings
@Route(.push) @Route(.modal)
var experimentalSettings = makeExperimentalSettings var experimentalSettings = makeExperimentalSettings
@Route(.push) @Route(.modal)
var log = makeLog var log = makeLog
@Route(.push) @Route(.modal)
var serverDetail = makeServerDetail var serverDetail = makeServerDetail
@Route(.push) @Route(.modal)
var videoPlayerSettings = makeVideoPlayerSettings var videoPlayerSettings = makeVideoPlayerSettings
@Route(.push) @Route(.modal)
var playbackQualitySettings = makePlaybackQualitySettings var playbackQualitySettings = makePlaybackQualitySettings
@Route(.push) @Route(.modal)
var userProfile = makeUserProfileSettings var userProfile = makeUserProfileSettings
#endif #endif

View File

@ -1,69 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Factory
import SwiftUI
// TODO: move popup to router
// - or, make tab view environment object
// TODO: fix weird tvOS icon rendering
struct MainTabView: View {
#if os(iOS)
@StateObject
private var tabCoordinator = TabCoordinator {
TabItem.home
TabItem.search
TabItem.media
}
#else
@StateObject
private var tabCoordinator = TabCoordinator {
TabItem.home
TabItem.library(
title: L10n.tvShows,
systemName: "tv",
filters: .init(itemTypes: [.series])
)
TabItem.library(
title: L10n.movies,
systemName: "film",
filters: .init(itemTypes: [.movie])
)
TabItem.search
TabItem.media
TabItem.settings
}
#endif
@ViewBuilder
var body: some View {
TabView(selection: $tabCoordinator.selectedTabID) {
ForEach(tabCoordinator.tabs, id: \.item.id) { tab in
NavigationInjectionView(
coordinator: tab.coordinator
) {
tab.item.content
}
.environmentObject(tabCoordinator)
.environment(\.tabItemSelected, tab.publisher)
.tabItem {
Label(
tab.item.title,
systemImage: tab.item.systemImage
)
.labelStyle(tab.item.labelStyle)
.symbolRenderingMode(.monochrome)
.eraseToAnyView()
}
.tag(tab.item.id)
}
}
}
}

View File

@ -1,50 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import SwiftUI
@MainActor
final class TabCoordinator: ObservableObject {
struct SelectedEvent {
let isRoot: Bool
let isRepeat: Bool
}
typealias TabData = (
item: TabItem,
coordinator: NavigationCoordinator,
publisher: TabItemSelectedPublisher
)
@Published
var selectedTabID: String! = nil {
didSet {
guard let tab = tabs.first(property: \.item.id, equalTo: selectedTabID) else { return }
tab.publisher.send(
.init(
isRoot: tab.coordinator.path.isEmpty,
isRepeat: oldValue == selectedTabID
)
)
}
}
@Published
var tabs: [TabData] = []
init(@ArrayBuilder<TabItem> tabs: () -> [TabItem]) {
let tabs = tabs()
self.tabs = tabs.map { tab in
let coordinator = NavigationCoordinator()
let event = TabItemSelectedPublisher()
return (tab, coordinator, event)
}
}
}

View File

@ -1,95 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import SwiftUI
// TODO: selected icon
struct TabItem: Identifiable, Hashable {
let content: AnyView
let id: String
let title: String
let systemImage: String
let labelStyle: any LabelStyle
init(
id: String,
title: String,
systemImage: String,
labelStyle: some LabelStyle = .titleAndIcon,
@ViewBuilder content: () -> some View
) {
self.content = AnyView(content())
self.id = id
self.title = title
self.systemImage = systemImage
self.labelStyle = labelStyle
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.id == rhs.id
}
}
extension TabItem {
static let home = TabItem(
id: "home",
title: L10n.home,
systemImage: "house"
) {
HomeView()
}
static func library(
title: String,
systemName: String,
filters: ItemFilterCollection
) -> TabItem {
TabItem(
id: "library-\(UUID().uuidString)",
title: title,
systemImage: systemName
) {
let viewModel = ItemLibraryViewModel(
filters: filters
)
PagingLibraryView(viewModel: viewModel)
}
}
static let media = TabItem(
id: "media",
title: L10n.media,
systemImage: "rectangle.stack.fill"
) {
MediaView()
}
static let search = TabItem(
id: "search",
title: L10n.search,
systemImage: "magnifyingglass"
) {
SearchView()
}
static let settings = TabItem(
id: "settings",
title: L10n.settings,
systemImage: "gearshape",
labelStyle: .iconOnly
) {
SettingsView()
}
}

View File

@ -1,31 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import SwiftUI
extension TabCoordinator {
typealias TabItemSelectedPublisher = LegacyEventPublisher<TabCoordinator.SelectedEvent>
}
@propertyWrapper
struct TabItemSelected: DynamicProperty {
@Environment(\.tabItemSelected)
private var publisher
var wrappedValue: TabCoordinator.TabItemSelectedPublisher {
publisher
}
}
extension EnvironmentValues {
@Entry
var tabItemSelected: TabCoordinator.TabItemSelectedPublisher = .init()
}

View File

@ -1,61 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Foundation
import JellyfinAPI
import Stinsen
import SwiftUI
final class UserSignInCoordinator: NavigationCoordinatable {
struct SecurityParameters {
let pinHint: Binding<String>
let accessPolicy: Binding<UserAccessPolicy>
}
let stack = NavigationStack(initial: \UserSignInCoordinator.start)
@Root
var start = makeStart
@Route(.push)
var quickConnect = makeQuickConnect
#if os(iOS)
@Route(.push)
var security = makeSecurity
#endif
private let server: ServerState
init(server: ServerState) {
self.server = server
}
func makeQuickConnect(quickConnect: QuickConnect) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
QuickConnectView(quickConnect: quickConnect)
}
}
#if os(iOS)
func makeSecurity(parameters: SecurityParameters) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
UserSignInView.SecurityView(
pinHint: parameters.pinHint,
accessPolicy: parameters.accessPolicy
)
}
}
#endif
@ViewBuilder
func makeStart() -> some View {
UserSignInView(server: server)
}
}

View File

@ -1,61 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Foundation
import JellyfinAPI
import Stinsen
import SwiftUI
final class UserSignInCoordinator: NavigationCoordinatable {
struct SecurityParameters {
let pinHint: Binding<String>
let accessPolicy: Binding<UserAccessPolicy>
}
let stack = NavigationStack(initial: \UserSignInCoordinator.start)
@Root
var start = makeStart
@Route(.push)
var quickConnect = makeQuickConnect
#if os(iOS)
@Route(.push)
var security = makeSecurity
#endif
private let server: ServerState
init(server: ServerState) {
self.server = server
}
func makeQuickConnect(quickConnect: QuickConnect) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
QuickConnectView(quickConnect: quickConnect)
}
}
#if os(iOS)
func makeSecurity(parameters: SecurityParameters) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
UserSignInView.SecurityView(
pinHint: parameters.pinHint,
accessPolicy: parameters.accessPolicy
)
}
}
#endif
@ViewBuilder
func makeStart() -> some View {
UserSignInView(server: server)
}
}

View File

@ -23,11 +23,11 @@ final class UserSignInCoordinator: NavigationCoordinatable {
@Root @Root
var start = makeStart var start = makeStart
@Route(.push) @Route(.modal)
var quickConnect = makeQuickConnect var quickConnect = makeQuickConnect
#if os(iOS) #if os(iOS)
@Route(.push) @Route(.modal)
var security = makeSecurity var security = makeSecurity
#endif #endif

View File

@ -1,22 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import BlurHashKit
import SwiftUI
extension BlurHash {
var averageLinearColor: Color {
let color = averageLinearRGB
return Color(
red: Double(color.0),
green: Double(color.1),
blue: Double(color.2)
)
}
}

View File

@ -1,33 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import SwiftUI
@propertyWrapper
struct BoxedPublished<Value>: DynamicProperty {
@StateObject
var storage: PublishedBox<Value>
init(wrappedValue: Value) {
self._storage = StateObject(wrappedValue: PublishedBox(initialValue: wrappedValue))
}
var wrappedValue: Value {
get { storage.value }
nonmutating set { storage.value = newValue }
}
var projectedValue: Published<Value>.Publisher {
storage.$value
}
var box: PublishedBox<Value> {
storage
}
}

View File

@ -1,32 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
@inlinable
func abs(_ d: Duration) -> Duration {
d < .zero ? (.zero - d) : d
}
extension Duration {
/// Represent Jellyfin ticks as a Duration
static func ticks(_ ticks: Int) -> Duration {
Duration.microseconds(Int64(ticks) / 10)
}
var microseconds: Int64 {
(components.attoseconds / 1_000_000_000_000) + components.seconds * 1_000_000
}
var seconds: Double {
Double(components.seconds) + Double(components.attoseconds) * 1e-18
}
var ticks: Int {
Int(microseconds * 10)
}
}

View File

@ -1,15 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import SwiftUI
extension FocusedValues {
@Entry
var focusedPoster: AnyPoster?
}

View File

@ -1,14 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import SwiftUI
extension AnyView: PlatformView {
var iOSView: some View { self }
var tvOSView: some View { self }
}

View File

@ -105,13 +105,6 @@ extension BaseItemDto {
} }
logger.debug("liveVideoPlayerViewModel matchingMediaSource being returned") logger.debug("liveVideoPlayerViewModel matchingMediaSource being returned")
logger.debug(" TranscodingURL: \(matchingMediaSource.transcodingURL ?? "nil")")
logger.debug(" Path: \(matchingMediaSource.path ?? "nil")")
logger.debug(" Container: \(matchingMediaSource.container ?? "nil")")
logger.debug(" SupportsDirectPlay: \(matchingMediaSource.isSupportsDirectPlay ?? false)")
logger.debug(" PlaySessionID: \(response.value.playSessionID ?? "nil")")
logger.debug(" LiveStreamID: \(matchingMediaSource.liveStreamID ?? "nil")")
logger.debug(" OpenToken: \(matchingMediaSource.openToken ?? "nil")")
return try matchingMediaSource.liveVideoPlayerViewModel( return try matchingMediaSource.liveVideoPlayerViewModel(
with: self, with: self,
playSessionID: response.value.playSessionID! playSessionID: response.value.playSessionID!

View File

@ -1,34 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Foundation
import JellyfinAPI
extension CountryInfo: Displayable {
var displayTitle: String {
if let twoLetterISORegionName, let name = Locale.current.localizedString(forRegionCode: twoLetterISORegionName) {
return name
}
if let threeLetterISORegionName, let name = Locale.current.localizedString(forRegionCode: threeLetterISORegionName) {
return name
}
return displayName ?? L10n.unknown
}
}
extension CountryInfo {
static var none: CountryInfo {
CountryInfo(
displayName: L10n.none
)
}
}

View File

@ -1,36 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Foundation
import JellyfinAPI
extension CultureDto: Displayable {
var displayTitle: String {
if let twoLetterISOLanguageName,
let name = Locale.current.localizedString(forLanguageCode: twoLetterISOLanguageName)
{
return name
}
if let threeLetterISOLanguageNames, let displayName = threeLetterISOLanguageNames
.compactMap({ Locale.current.localizedString(forLanguageCode: $0) })
.first
{
return displayName
}
return displayName ?? L10n.unknown
}
static var none: CultureDto {
CultureDto(
displayName: L10n.none
)
}
}

View File

@ -72,91 +72,34 @@ extension MediaSourceInfo {
let playbackURL: URL let playbackURL: URL
let playMethod: PlayMethod let playMethod: PlayMethod
print("🎬 liveVideoPlayerViewModel: Starting for item \(item.displayTitle)") if let transcodingURL {
print("🎬 Server URL: \(userSession.server.currentURL)") guard let fullTranscodeURL = URL(string: transcodingURL, relativeTo: userSession.server.currentURL)
print("🎬 TranscodingURL: \(transcodingURL ?? "nil")") else { throw JellyfinAPIError("Unable to construct transcoded url") }
print("🎬 Path: \(self.path ?? "nil")")
print("🎬 SupportsDirectPlay: \(self.isSupportsDirectPlay ?? false)")
print("🎬 MediaSourceInfo ID: \(self.id ?? "nil")")
print("🎬 MediaSourceInfo Name: \(self.name ?? "nil")")
print("🎬 Container: \(self.container ?? "nil")")
print("🎬 PlaySessionID: \(playSessionID)")
print("🎬 LiveStreamID: \(self.liveStreamID ?? "nil")")
print("🎬 OpenToken: \(self.openToken ?? "nil")")
// For Live TV: Try direct Dispatcharr proxy URL FIRST (Jellyfin's endpoints are broken)
if let path = self.path, let pathURL = URL(string: path), pathURL.scheme != nil {
// Use direct Dispatcharr proxy stream (MPEG-TS over HTTP)
playbackURL = pathURL
playMethod = .directPlay
print("🎬 Using direct Dispatcharr proxy path: \(playbackURL)")
print("🎬 Absolute URL: \(playbackURL.absoluteString)")
} else if let transcodingURL {
// Fallback to Jellyfin transcoding URL (doesn't work for Dispatcharr channels)
let liveTranscodingURL = transcodingURL.replacingOccurrences(of: "/master.m3u8", with: "/live.m3u8")
guard var fullTranscodeURL = userSession.client.fullURL(with: liveTranscodingURL)
else { throw JellyfinAPIError("Unable to make transcode URL") }
// Add LiveStreamId parameter using URLComponents for proper encoding
if let openToken = self.openToken, var components = URLComponents(url: fullTranscodeURL, resolvingAgainstBaseURL: false) {
var queryItems = components.queryItems ?? []
queryItems.append(URLQueryItem(name: "LiveStreamId", value: openToken))
components.queryItems = queryItems
if let urlWithLiveStreamId = components.url {
fullTranscodeURL = urlWithLiveStreamId
print("🎬 Added LiveStreamId parameter: \(openToken)")
}
}
playbackURL = fullTranscodeURL playbackURL = fullTranscodeURL
playMethod = .transcode playMethod = .transcode
print("🎬 Using live transcoding URL (converted from master): \(playbackURL)") } else if self.isSupportsDirectPlay ?? false, let path = self.path, let playbackUrl = URL(string: path) {
print("🎬 Absolute URL: \(playbackURL.absoluteString)")
} else if false, let path = self.path, let pathURL = URL(string: path), pathURL.scheme != nil {
// Direct path disabled - fails with AVPlayer connection error
playbackURL = pathURL
playMethod = .directPlay
print("🎬 Using direct path URL (absolute): \(playbackURL)")
print("🎬 Absolute URL: \(playbackURL.absoluteString)")
} else if false, self.isSupportsDirectPlay ?? false, let path = self.path, let playbackUrl = URL(string: path) {
// Relative direct play disabled
playbackURL = playbackUrl playbackURL = playbackUrl
playMethod = .directPlay playMethod = .directPlay
print("🎬 Using direct play URL (relative): \(playbackURL)")
print("🎬 Absolute URL: \(playbackURL.absoluteString)")
} else { } else {
// Use Jellyfin's live.m3u8 endpoint for Live TV (same as web browser) let videoStreamParameters = Paths.GetVideoStreamParameters(
// Construct URL: /videos/{id}/live.m3u8?DeviceId=...&MediaSourceId=...&PlaySessionId=...&api_key=... isStatic: true,
let deviceId = userSession.client.configuration.deviceID ?? "unknown" tag: item.etag,
let apiKey = userSession.client.accessToken ?? "" playSessionID: playSessionID,
mediaSourceID: id
)
var urlComponents = URLComponents() let videoStreamRequest = Paths.getVideoStream(
urlComponents.scheme = userSession.server.currentURL.scheme itemID: item.id!,
urlComponents.host = userSession.server.currentURL.host parameters: videoStreamParameters
urlComponents.port = userSession.server.currentURL.port )
urlComponents.path = "/videos/\(item.id!)/live.m3u8"
urlComponents.queryItems = [
URLQueryItem(name: "DeviceId", value: deviceId),
URLQueryItem(name: "MediaSourceId", value: id),
URLQueryItem(name: "PlaySessionId", value: playSessionID),
URLQueryItem(name: "api_key", value: apiKey),
]
guard let liveURL = urlComponents.url else { guard let fullURL = userSession.client.fullURL(with: videoStreamRequest) else {
print("🎬 ERROR: Unable to construct live.m3u8 URL") throw JellyfinAPIError("Unable to construct transcoded url")
throw JellyfinAPIError("Unable to construct live.m3u8 URL")
} }
playbackURL = liveURL playbackURL = fullURL
playMethod = .directPlay playMethod = .directPlay
print("🎬 Using live.m3u8 URL: \(playbackURL)")
print("🎬 Absolute URL: \(playbackURL.absoluteString)")
} }
print("🎬 Final playback URL absolute string: \(playbackURL.absoluteString)")
print("🎬 Play method: \(playMethod)")
let videoStreams = mediaStreams?.filter { $0.type == .video } ?? [] let videoStreams = mediaStreams?.filter { $0.type == .video } ?? []
let audioStreams = mediaStreams?.filter { $0.type == .audio } ?? [] let audioStreams = mediaStreams?.filter { $0.type == .audio } ?? []
let subtitleStreams = mediaStreams?.filter { $0.type == .subtitle } ?? [] let subtitleStreams = mediaStreams?.filter { $0.type == .subtitle } ?? []

View File

@ -1,35 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import SwiftUI
// TODO: rename as not only used in section footers
extension LabelStyle where Self == SectionFooterWithImageLabelStyle<AnyShapeStyle> {
static func sectionFooterWithImage<ImageStyle: ShapeStyle>(
imageStyle: ImageStyle
) -> SectionFooterWithImageLabelStyle<ImageStyle> {
SectionFooterWithImageLabelStyle(imageStyle: imageStyle)
}
}
struct SectionFooterWithImageLabelStyle<ImageStyle: ShapeStyle>: LabelStyle {
let imageStyle: ImageStyle
func makeBody(configuration: Configuration) -> some View {
HStack {
configuration.icon
.foregroundStyle(imageStyle)
.fontWeight(.bold)
configuration.title
}
}
}

View File

@ -23,7 +23,7 @@ extension DataCache.Swiftfin {
static let posters: DataCache? = { static let posters: DataCache? = {
let dataCache = try? DataCache(name: "se.ashik.jellyflood/Posters") { name in let dataCache = try? DataCache(name: "org.ashik.jellypig/Posters") { name in
guard let url = name.url else { return nil } guard let url = name.url else { return nil }
return ImagePipeline.cacheKey(for: url) return ImagePipeline.cacheKey(for: url)
} }
@ -40,7 +40,7 @@ extension DataCache.Swiftfin {
return nil return nil
} }
let path = root.appendingPathComponent("Caches/se.ashik.jellyflood.local", isDirectory: true) let path = root.appendingPathComponent("Caches/org.ashik.jellypig.local", isDirectory: true)
let dataCache = try? DataCache(path: path) { name in let dataCache = try? DataCache(path: path) { name in

View File

@ -1,60 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Defaults
import SwiftUI
struct GaugeProgressViewStyle: ProgressViewStyle {
@Default(.accentColor)
private var accentColor
@State
private var contentSize: CGSize = .zero
private let lineWidthRatio: CGFloat
private let systemImage: String?
init(systemImage: String? = nil) {
self.lineWidthRatio = systemImage == nil ? 0.2 : 0.125
self.systemImage = systemImage
}
func makeBody(configuration: Configuration) -> some View {
ZStack {
if let systemImage {
Image(systemName: systemImage)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: contentSize.width / 2.5, maxHeight: contentSize.height / 2.5)
.foregroundStyle(.secondary)
.padding(6)
}
Circle()
.stroke(
Color.gray.opacity(0.2),
lineWidth: contentSize.width * lineWidthRatio
)
Circle()
.trim(from: 0, to: configuration.fractionCompleted ?? 0)
.stroke(
accentColor,
style: StrokeStyle(
lineWidth: contentSize.width * lineWidthRatio,
lineCap: .round
)
)
.rotationEffect(.degrees(-90))
}
.animation(.linear(duration: 0.1), value: configuration.fractionCompleted)
.trackingSize($contentSize)
}
}

View File

@ -1,60 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import SwiftUI
struct PlaybackProgressViewStyle: ProgressViewStyle {
enum CornerStyle {
case round
case square
}
@State
private var contentSize: CGSize = .zero
var secondaryProgress: Double?
var cornerStyle: CornerStyle
@ViewBuilder
private func buildCapsule(for progress: Double) -> some View {
Rectangle()
.cornerRadius(
cornerStyle == .round ? contentSize.height / 2 : 0,
corners: [.topLeft, .bottomLeft]
)
.frame(width: contentSize.width * clamp(progress, min: 0, max: 1) + contentSize.height)
.offset(x: -contentSize.height)
}
func makeBody(configuration: Configuration) -> some View {
Capsule()
.foregroundStyle(.secondary)
.opacity(0.2)
.overlay(alignment: .leading) {
ZStack(alignment: .leading) {
if let secondaryProgress,
secondaryProgress > 0
{
buildCapsule(for: secondaryProgress)
.foregroundStyle(.tertiary)
}
if let fractionCompleted = configuration.fractionCompleted {
buildCapsule(for: fractionCompleted)
.foregroundStyle(.primary)
}
}
}
.trackingSize($contentSize)
.mask {
Capsule()
}
}
}

View File

@ -1,33 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import SwiftUI
extension ProgressViewStyle where Self == GaugeProgressViewStyle {
static var gauge: GaugeProgressViewStyle {
GaugeProgressViewStyle()
}
static func gauge(systemImage: String) -> GaugeProgressViewStyle {
GaugeProgressViewStyle(systemImage: systemImage)
}
}
extension ProgressViewStyle where Self == PlaybackProgressViewStyle {
static var playback: Self { .init(secondaryProgress: nil, cornerStyle: .round) }
func secondaryProgress(_ progress: Double?) -> Self {
copy(self, modifying: \.secondaryProgress, to: progress)
}
var square: Self {
copy(self, modifying: \.cornerStyle, to: .square)
}
}

View File

@ -1,20 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Combine
/// A box for a `Published` value
class PublishedBox<Value>: ObservableObject {
@Published
var value: Value
init(initialValue: Value) {
self.value = initialValue
}
}

View File

@ -1,24 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import SwiftUI
extension Section where Parent == Text, Footer == Text, Content: View {
init(
_ header: String,
footer: String,
@ViewBuilder content: @escaping () -> Content
) {
self.init(content: content) {
Text(header)
} footer: {
Text(footer)
}
}
}

View File

@ -1,81 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import UIKit
extension UIImage {
func getTileImage(
columns: Int,
rows: Int,
index: Int
) -> UIImage? {
let x = index % columns
let y = index / columns
// Check if the tile index is within the valid range
// guard x >= 0, y >= 0, x < columns, y < rows else {
// return nil
// }
// Use integer arithmetic for tile dimensions and positions
let imageWidth = Int(size.width)
let imageHeight = Int(size.height)
let tileWidth = imageWidth / columns
let tileHeight = imageHeight / rows
// Calculate the rectangle using integer values
let rect = CGRect(
x: x * tileWidth,
y: y * tileHeight,
width: tileWidth,
height: tileHeight
)
// This check is now redundant because of the earlier guard statement
// guard rect.maxX <= imageWidth && rect.maxY <= imageHeight else {
// return nil
// }
if let cgImage = cgImage?.cropping(to: rect) {
return UIImage(cgImage: cgImage)
}
return nil
// guard index >= 0 else {
// return nil
// }
//
// let imageWidth = size.width
// let imageHeight = size.height
//
// let tileWidth = imageWidth / CGFloat(columns)
// let tileHeight = imageHeight / CGFloat(rows)
//
// let x = (index % columns)
// let y = (index / columns)
//
// let rect = CGRect(
// x: CGFloat(x) * tileWidth,
// y: CGFloat(y) * tileHeight,
// width: tileWidth,
// height: tileHeight
// )
//
// guard rect.maxX <= imageWidth && rect.maxY <= imageHeight else {
// return nil
// }
//
// if let cgImage = cgImage?.cropping(to: rect) {
// return UIImage(cgImage: cgImage)
// }
//
// return nil
}
}

View File

@ -1,16 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import SwiftUI
extension UnitPoint {
var inverted: UnitPoint {
UnitPoint(x: 1 - x, y: 1 - y)
}
}

View File

@ -1,217 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
// swiftlint:disable all
// Generated using SwiftGen https://github.com/SwiftGen/SwiftGen
#if os(macOS)
import AppKit
#elseif os(iOS)
import UIKit
#elseif os(tvOS) || os(watchOS)
import UIKit
#endif
#if canImport(SwiftUI)
import SwiftUI
#endif
// Deprecated typealiases
@available(*, deprecated, renamed: "ImageAsset.Image", message: "This typealias will be removed in SwiftGen 7.0")
typealias AssetImageTypeAlias = ImageAsset.Image
// swiftlint:disable superfluous_disable_command file_length implicit_return
// MARK: - Asset Catalogs
// swiftlint:disable identifier_name line_length nesting type_body_length type_name
enum Asset {
static let deviceBrowserChrome = ImageAsset(name: "Device-browser-chrome")
static let deviceBrowserEdge = ImageAsset(name: "Device-browser-edge")
static let deviceBrowserEdgechromium = ImageAsset(name: "Device-browser-edgechromium")
static let deviceBrowserFirefox = ImageAsset(name: "Device-browser-firefox")
static let deviceBrowserHtml5 = ImageAsset(name: "Device-browser-html5")
static let deviceBrowserMsie = ImageAsset(name: "Device-browser-msie")
static let deviceBrowserOpera = ImageAsset(name: "Device-browser-opera")
static let deviceBrowserSafari = ImageAsset(name: "Device-browser-safari")
static let deviceClientAndroid = ImageAsset(name: "Device-client-android")
static let deviceClientApple = ImageAsset(name: "Device-client-apple")
static let deviceClientFinamp = ImageAsset(name: "Device-client-finamp")
static let deviceClientKodi = ImageAsset(name: "Device-client-kodi")
static let deviceClientPlaystation = ImageAsset(name: "Device-client-playstation")
static let deviceClientRoku = ImageAsset(name: "Device-client-roku")
static let deviceClientSamsungtv = ImageAsset(name: "Device-client-samsungtv")
static let deviceClientWebos = ImageAsset(name: "Device-client-webos")
static let deviceClientWindows = ImageAsset(name: "Device-client-windows")
static let deviceClientXbox = ImageAsset(name: "Device-client-xbox")
static let deviceOtherHomeassistant = ImageAsset(name: "Device-other-homeassistant")
static let deviceOtherOther = ImageAsset(name: "Device-other-other")
static let jellyfinBlobBlue = ImageAsset(name: "jellyfin-blob-blue")
static let tomatoFresh = SymbolAsset(name: "tomato.fresh")
static let tomatoRotten = SymbolAsset(name: "tomato.rotten")
}
// swiftlint:enable identifier_name line_length nesting type_body_length type_name
// MARK: - Implementation Details
struct ImageAsset {
fileprivate(set) var name: String
#if os(macOS)
typealias Image = NSImage
#elseif os(iOS) || os(tvOS) || os(watchOS)
typealias Image = UIImage
#endif
@available(iOS 8.0, tvOS 9.0, watchOS 2.0, macOS 10.7, *)
var image: Image {
let bundle = BundleToken.bundle
#if os(iOS) || os(tvOS)
let image = Image(named: name, in: bundle, compatibleWith: nil)
#elseif os(macOS)
let name = NSImage.Name(self.name)
let image = (bundle == .main) ? NSImage(named: name) : bundle.image(forResource: name)
#elseif os(watchOS)
let image = Image(named: name)
#endif
guard let result = image else {
fatalError("Unable to load image asset named \(name).")
}
return result
}
#if os(iOS) || os(tvOS)
@available(iOS 8.0, tvOS 9.0, *)
func image(compatibleWith traitCollection: UITraitCollection) -> Image {
let bundle = BundleToken.bundle
guard let result = Image(named: name, in: bundle, compatibleWith: traitCollection) else {
fatalError("Unable to load image asset named \(name).")
}
return result
}
#endif
#if canImport(SwiftUI)
@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *)
var swiftUIImage: SwiftUI.Image {
SwiftUI.Image(asset: self)
}
#endif
}
extension ImageAsset.Image {
@available(iOS 8.0, tvOS 9.0, watchOS 2.0, *)
@available(
macOS,
deprecated,
message: "This initializer is unsafe on macOS, please use the ImageAsset.image property"
)
convenience init?(asset: ImageAsset) {
#if os(iOS) || os(tvOS)
let bundle = BundleToken.bundle
self.init(named: asset.name, in: bundle, compatibleWith: nil)
#elseif os(macOS)
self.init(named: NSImage.Name(asset.name))
#elseif os(watchOS)
self.init(named: asset.name)
#endif
}
}
#if canImport(SwiftUI)
@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *)
extension SwiftUI.Image {
init(asset: ImageAsset) {
let bundle = BundleToken.bundle
self.init(asset.name, bundle: bundle)
}
init(asset: ImageAsset, label: Text) {
let bundle = BundleToken.bundle
self.init(asset.name, bundle: bundle, label: label)
}
init(decorative asset: ImageAsset) {
let bundle = BundleToken.bundle
self.init(decorative: asset.name, bundle: bundle)
}
}
#endif
struct SymbolAsset {
fileprivate(set) var name: String
#if os(iOS) || os(tvOS) || os(watchOS)
@available(iOS 13.0, tvOS 13.0, watchOS 6.0, *)
typealias Configuration = UIImage.SymbolConfiguration
typealias Image = UIImage
@available(iOS 12.0, tvOS 12.0, watchOS 5.0, *)
var image: Image {
let bundle = BundleToken.bundle
#if os(iOS) || os(tvOS)
let image = Image(named: name, in: bundle, compatibleWith: nil)
#elseif os(watchOS)
let image = Image(named: name)
#endif
guard let result = image else {
fatalError("Unable to load symbol asset named \(name).")
}
return result
}
@available(iOS 13.0, tvOS 13.0, watchOS 6.0, *)
func image(with configuration: Configuration) -> Image {
let bundle = BundleToken.bundle
guard let result = Image(named: name, in: bundle, with: configuration) else {
fatalError("Unable to load symbol asset named \(name).")
}
return result
}
#endif
#if canImport(SwiftUI)
@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *)
var swiftUIImage: SwiftUI.Image {
SwiftUI.Image(asset: self)
}
#endif
}
#if canImport(SwiftUI)
@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *)
extension SwiftUI.Image {
init(asset: SymbolAsset) {
let bundle = BundleToken.bundle
self.init(asset.name, bundle: bundle)
}
init(asset: SymbolAsset, label: Text) {
let bundle = BundleToken.bundle
self.init(asset.name, bundle: bundle, label: label)
}
init(decorative asset: SymbolAsset) {
let bundle = BundleToken.bundle
self.init(decorative: asset.name, bundle: bundle)
}
}
#endif
// swiftlint:disable convenience_type
private final class BundleToken {
static let bundle: Bundle = {
#if SWIFT_PACKAGE
return Bundle.module
#else
return Bundle(for: BundleToken.self)
#endif
}()
}
// swiftlint:enable convenience_type

View File

@ -1,73 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Factory
import Logging
extension Logger {
static func swiftfin() -> Logger {
Logger(label: "org.jellyfin.swiftfin")
}
}
struct SwiftfinConsoleHandler: LogHandler {
var logLevel: Logger.Level = .trace
var metadata: Logger.Metadata = [:]
subscript(metadataKey key: String) -> Logger.Metadata.Value? {
get {
metadata[key]
}
set(newValue) {
metadata[key] = newValue
}
}
func log(
level: Logger.Level,
message: Logger.Message,
metadata: Logger.Metadata?,
source: String,
file: String,
function: String,
line: UInt
) {
let line = "[\(level.emoji) \(level.rawValue.capitalized)] \(file.shortFileName)#\(line):\(function) \(message)"
let meta = (metadata ?? [:]).merging(self.metadata) { _, new in new }
let metadataString = meta.map { "\t- \($0): \($1)" }.joined(separator: "\n")
print(line)
if metadataString.isNotEmpty {
print(metadataString)
}
}
}
extension Logger.Level {
var emoji: String {
switch self {
case .trace:
return "🟣"
case .debug:
return "🔵"
case .info:
return "🟢"
case .notice:
return "🟠"
case .warning:
return "🟡"
case .error:
return "🔴"
case .critical:
return "💥"
}
}
}

View File

@ -1,57 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Foundation
import JellyfinAPI
import Pulse
private let redactedMessage = "<Redacted by Swiftfin>"
extension NetworkLogger {
static func swiftfin() -> NetworkLogger {
var configuration = NetworkLogger.Configuration()
configuration.willHandleEvent = { event -> LoggerStore.Event? in
if case var LoggerStore.Event.networkTaskCompleted(task) = event {
guard let url = task.originalRequest.url,
let requestBody = task.requestBody
else {
return event
}
let pathComponents = url.pathComponents
if pathComponents.last == "AuthenticateByName",
var body = try? JSONDecoder().decode(AuthenticateUserByName.self, from: requestBody)
{
body.pw = redactedMessage
task.requestBody = try? JSONEncoder().encode(body)
return LoggerStore.Event.networkTaskCompleted(task)
}
if pathComponents.last == "Password",
var body = try? JSONDecoder().decode(UpdateUserPassword.self, from: requestBody)
{
body.currentPassword = redactedMessage
body.currentPw = redactedMessage
body.newPw = redactedMessage
body.isResetPassword = nil
task.requestBody = try? JSONEncoder().encode(body)
return LoggerStore.Event.networkTaskCompleted(task)
}
}
return event
}
return NetworkLogger(configuration: configuration)
}
}

View File

@ -1,84 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import CoreStore
import Logging
struct SwiftfinCorestoreLogger: CoreStoreLogger {
private let logger = Logger.swiftfin()
func log(
error: CoreStoreError,
message: String,
fileName: StaticString,
lineNumber: Int,
functionName: StaticString
) {
logger.error(
"\(message)",
metadata: nil,
source: "Corestore",
file: fileName.description,
function: functionName.description,
line: UInt(lineNumber)
)
}
func log(
level: LogLevel,
message: String,
fileName: StaticString,
lineNumber: Int,
functionName: StaticString
) {
logger.log(
level: level.asSwiftLog,
"\(message)",
metadata: nil,
source: "Corestore",
file: fileName.description,
function: functionName.description,
line: UInt(lineNumber)
)
}
func assert(
_ condition: @autoclosure () -> Bool,
message: @autoclosure () -> String,
fileName: StaticString,
lineNumber: Int,
functionName: StaticString
) {
guard !condition() else { return }
logger.critical(
"\(message())",
metadata: nil,
source: "Corestore",
file: fileName.description,
function: functionName.description,
line: UInt(lineNumber)
)
}
}
extension CoreStore.LogLevel {
var asSwiftLog: Logger.Level {
switch self {
case .trace:
return .trace
case .notice:
return .debug
case .warning:
return .warning
case .fatal:
return .critical
}
}
}

View File

@ -1,38 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import SwiftUI
enum ActiveSessionFilter: String, CaseIterable, SystemImageable, Displayable, Storable {
case all
case active
case inactive
var displayTitle: String {
switch self {
case .all:
return L10n.all
case .active:
return L10n.active
case .inactive:
return L10n.inactive
}
}
var systemImage: String {
switch self {
case .all:
return "line.3.horizontal"
case .active:
return "play"
case .inactive:
return "play.slash"
}
}
}

View File

@ -53,20 +53,4 @@ extension ChannelProgram: Poster {
var systemImage: String { var systemImage: String {
channel.systemImage channel.systemImage
} }
func portraitImageSources(maxWidth: CGFloat? = nil, quality: Int? = nil) -> [ImageSource] {
channel.portraitImageSources(maxWidth: maxWidth)
}
func landscapeImageSources(maxWidth: CGFloat? = nil, quality: Int? = nil) -> [ImageSource] {
channel.landscapeImageSources(maxWidth: maxWidth)
}
func cinematicImageSources(maxWidth: CGFloat? = nil, quality: Int? = nil) -> [ImageSource] {
channel.cinematicImageSources(maxWidth: maxWidth)
}
func squareImageSources(maxWidth: CGFloat? = nil, quality: Int? = nil) -> [ImageSource] {
channel.squareImageSources(maxWidth: maxWidth)
}
} }

View File

@ -1,44 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import UIKit
class DirectionalPanGestureRecognizer: UIPanGestureRecognizer {
var direction: Direction
init(direction: Direction, target: AnyObject, action: Selector) {
self.direction = direction
super.init(target: target, action: action)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesMoved(touches, with: event)
if state == .began {
let velocity = velocity(in: view)
let isUp = velocity.y < 0
let isHorizontal = velocity.y.magnitude < velocity.x.magnitude
let isVertical = velocity.x.magnitude < velocity.y.magnitude
switch direction {
case .all: ()
case .allButDown where isUp || isHorizontal: ()
case .horizontal where isHorizontal: ()
case .vertical where isVertical: ()
case .up where isVertical && velocity.y < 0: ()
case .down where isVertical && velocity.y > 0: ()
case .left where isHorizontal && velocity.x < 0: ()
case .right where isHorizontal && velocity.x > 0: ()
default:
state = .cancelled
}
}
}
}

View File

@ -1,28 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
enum DoubleTouchGestureAction: String, GestureAction {
case none
case aspectFill
case gestureLock
case pausePlay
var displayTitle: String {
switch self {
case .none:
return L10n.none
case .aspectFill:
return L10n.aspectFill
case .gestureLock:
return L10n.gestureLock
case .pausePlay:
return L10n.playAndPause
}
}
}

View File

@ -1,12 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
// `none` is used since values aren't supported in Defaults
// https://github.com/sindresorhus/Defaults/issues/54
protocol GestureAction: CaseIterable, Displayable, Storable {}

View File

@ -1,22 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
enum LongPressGestureAction: String, GestureAction {
case none
case gestureLock
var displayTitle: String {
switch self {
case .none:
return L10n.none
case .gestureLock:
return L10n.gestureLock
}
}
}

View File

@ -1,22 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
enum MultiTapGestureAction: String, GestureAction {
case none
case jump
var displayTitle: String {
switch self {
case .none:
return L10n.none
case .jump:
return L10n.jump
}
}
}

View File

@ -1,31 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
enum PanGestureAction: String, GestureAction {
case none
case brightness
case scrub
case slowScrub
case volume
var displayTitle: String {
switch self {
case .none:
return L10n.none
case .brightness:
return L10n.brightness
case .scrub:
return L10n.scrub
case .slowScrub:
return L10n.slowScrub
case .volume:
return L10n.volume
}
}
}

View File

@ -1,22 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
enum PinchGestureAction: String, GestureAction {
case none
case aspectFill
var displayTitle: String {
switch self {
case .none:
return L10n.none
case .aspectFill:
return L10n.aspectFill
}
}
}

View File

@ -1,22 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
enum SwipeGestureAction: String, GestureAction {
case none
case jump
var displayTitle: String {
switch self {
case .none:
return L10n.none
case .jump:
return L10n.jump
}
}
}

View File

@ -1,17 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import SwiftUI
struct IsStatusBarHiddenKey: PreferenceKey {
static var defaultValue: Bool = false
static func reduce(value: inout Bool, nextValue: () -> Bool) {
value = nextValue() || value
}
}

View File

@ -1,54 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import SwiftUI
@MainActor
@propertyWrapper
struct LazyState<Value>: @preconcurrency DynamicProperty {
final class Box {
private var value: Value!
private let thunk: () -> Value
var didThunk = false
var wrappedValue: Value {
value
}
func setup() {
value = thunk()
didThunk = true
}
init(wrappedValue thunk: @autoclosure @escaping () -> Value) {
self.thunk = thunk
}
}
@State
private var holder: Box
var wrappedValue: Value {
holder.wrappedValue
}
var projectedValue: Binding<Value> {
Binding(get: { wrappedValue }, set: { _ in })
}
func update() {
guard !holder.didThunk else { return }
holder.setup()
}
init(wrappedValue thunk: @autoclosure @escaping () -> Value) {
_holder = State(wrappedValue: Box(wrappedValue: thunk()))
}
}

View File

@ -1,83 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Defaults
import Foundation
// TODO: conform to `SystemImageable`
// - forward to systemImage, backward to secondarySystemImage
enum MediaJumpInterval: Storable, RawRepresentable {
typealias RawValue = Duration
case five
case ten
case fifteen
case thirty
case custom(interval: Duration)
init?(rawValue: Duration) {
switch rawValue {
case .seconds(5):
self = .five
case .seconds(10):
self = .ten
case .seconds(15):
self = .fifteen
case .seconds(30):
self = .thirty
default:
self = .custom(interval: rawValue)
}
}
var rawValue: Duration {
switch self {
case .five:
.seconds(5)
case .ten:
.seconds(10)
case .fifteen:
.seconds(15)
case .thirty:
.seconds(30)
case let .custom(interval):
interval
}
}
var forwardSystemImage: String {
switch self {
case .thirty:
"goforward.30"
case .fifteen:
"goforward.15"
case .ten:
"goforward.10"
case .five:
"goforward.5"
case .custom:
"goforward"
}
}
var backwardSystemImage: String {
switch self {
case .thirty:
"gobackward.30"
case .fifteen:
"gobackward.15"
case .ten:
"gobackward.10"
case .five:
"gobackward.5"
case .custom:
"gobackward"
}
}
}

View File

@ -1,218 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Defaults
import Factory
import Foundation
import JellyfinAPI
import Logging
// TODO: build report of determined values for playback information
// - transcode, video stream, path
extension MediaPlayerItem {
/// The main `MediaPlayerItem` builder for normal online usage.
static func build(
for initialItem: BaseItemDto,
mediaSource _initialMediaSource: MediaSourceInfo? = nil,
videoPlayerType: VideoPlayerType = Defaults[.VideoPlayer.videoPlayerType],
requestedBitrate: PlaybackBitrate = Defaults[.VideoPlayer.Playback.appMaximumBitrate],
compatibilityMode: PlaybackCompatibility = Defaults[.VideoPlayer.Playback.compatibilityMode],
modifyItem: ((inout BaseItemDto) -> Void)? = nil
) async throws -> MediaPlayerItem {
let logger = Logger.swiftfin()
guard let itemID = initialItem.id else {
logger.critical("No item ID!")
throw JellyfinAPIError(L10n.unknownError)
}
guard let userSession = Container.shared.currentUserSession() else {
logger.critical("No user session!")
throw JellyfinAPIError(L10n.unknownError)
}
var item = try await initialItem.getFullItem(userSession: userSession)
if let modifyItem {
modifyItem(&item)
}
guard let initialMediaSource = {
if let _initialMediaSource {
return _initialMediaSource
}
if let first = item.mediaSources?.first {
logger.trace("Using first media source for item \(itemID)")
return first
}
return nil
}() else {
logger.error("No media sources for item \(itemID)!")
throw JellyfinAPIError(L10n.unknownError)
}
let maxBitrate = try await requestedBitrate.getMaxBitrate()
let deviceProfile = DeviceProfile.build(
for: videoPlayerType,
compatibilityMode: compatibilityMode,
maxBitrate: maxBitrate
)
var playbackInfo = PlaybackInfoDto()
playbackInfo.isAutoOpenLiveStream = true
playbackInfo.deviceProfile = deviceProfile
playbackInfo.liveStreamID = initialMediaSource.liveStreamID
playbackInfo.maxStreamingBitrate = maxBitrate
playbackInfo.userID = userSession.user.id
let request = Paths.getPostedPlaybackInfo(
itemID: itemID,
playbackInfo
)
let response = try await userSession.client.send(request)
let mediaSource: MediaSourceInfo? = {
guard let mediaSources = response.value.mediaSources else { return nil }
if let matchingTag = mediaSources.first(where: { $0.eTag == initialMediaSource.eTag }) {
return matchingTag
}
for source in mediaSources {
if let openToken = source.openToken,
let id = source.id,
openToken.contains(id)
{
return source
}
}
logger.warning("Unable to find matching media source, defaulting to first media source")
return mediaSources.first
}()
guard let mediaSource else {
throw JellyfinAPIError("Unable to find media source for item")
}
guard let playSessionID = response.value.playSessionID else {
throw JellyfinAPIError("No associated play session ID")
}
let playbackURL = try Self.streamURL(
item: item,
mediaSource: mediaSource,
playSessionID: playSessionID,
userSession: userSession,
logger: logger
)
let previewImageProvider: (any PreviewImageProvider)? = {
let previewImageScrubbingSetting = StoredValues[.User.previewImageScrubbing]
lazy var chapterPreviewImageProvider: ChapterPreviewImageProvider? = {
if let chapters = item.fullChapterInfo, chapters.isNotEmpty {
return ChapterPreviewImageProvider(chapters: chapters)
}
return nil
}()
if case let PreviewImageScrubbingOption.trickplay(fallbackToChapters: fallbackToChapters) = previewImageScrubbingSetting {
if let mediaSourceID = mediaSource.id,
let trickplayInfo = item.trickplay?[mediaSourceID]?.first
{
return TrickplayPreviewImageProvider(
info: trickplayInfo.value,
itemID: itemID,
mediaSourceID: mediaSourceID,
runtime: item.runtime ?? .zero
)
}
if fallbackToChapters {
return chapterPreviewImageProvider
}
} else if previewImageScrubbingSetting == .chapters {
return chapterPreviewImageProvider
}
return nil
}()
return .init(
baseItem: item,
mediaSource: mediaSource,
playSessionID: playSessionID,
url: playbackURL,
requestedBitrate: requestedBitrate,
previewImageProvider: previewImageProvider,
thumbnailProvider: item.getNowPlayingImage
)
}
// TODO: audio type stream
// TODO: build live tv stream from Paths.getLiveHlsStream?
private static func streamURL(
item: BaseItemDto,
mediaSource: MediaSourceInfo,
playSessionID: String,
userSession: UserSession,
logger: Logger
) throws -> URL {
guard let itemID = item.id else {
throw JellyfinAPIError("No item ID while building online media player item!")
}
if let transcodingURL = mediaSource.transcodingURL {
logger.trace("Using transcoding URL for item \(itemID)")
guard let fullTranscodeURL = userSession.client.fullURL(with: transcodingURL)
else { throw JellyfinAPIError("Unable to make transcode URL") }
return fullTranscodeURL
}
if item.mediaType == .video, !item.isLiveStream {
logger.trace("Making video stream URL for item \(itemID)")
let videoStreamParameters = Paths.GetVideoStreamParameters(
isStatic: true,
tag: item.etag,
playSessionID: playSessionID,
mediaSourceID: itemID
)
let videoStreamRequest = Paths.getVideoStream(
itemID: itemID,
parameters: videoStreamParameters
)
guard let videoStreamURL = userSession.client.fullURL(with: videoStreamRequest)
else { throw JellyfinAPIError("Unable to make video stream URL") }
return videoStreamURL
}
logger.trace("Using media source path for item \(itemID)")
guard let path = mediaSource.path, let streamURL = URL(
string: path
) else { throw JellyfinAPIError("Unable to make stream URL") }
return streamURL
}
}

View File

@ -1,102 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import SwiftUI
// TODO: get preview image for current manager seconds?
// - would make scrubbing image possibly ready before scrubbing
// TODO: fix leaks
// - made from publishers of observers not being cancelled
@MainActor
class MediaPlayerItem: ViewModel, MediaPlayerObserver {
typealias ThumbnailProvider = () async -> UIImage?
@Published
var selectedAudioStreamIndex: Int? = nil {
didSet {
if let proxy = manager?.proxy as? any VideoMediaPlayerProxy {
proxy.setAudioStream(.init(index: selectedAudioStreamIndex))
}
}
}
@Published
var selectedSubtitleStreamIndex: Int? = nil {
didSet {
if let proxy = manager?.proxy as? any VideoMediaPlayerProxy {
proxy.setSubtitleStream(.init(index: selectedSubtitleStreamIndex))
}
}
}
weak var manager: MediaPlayerManager? {
didSet {
for var o in observers {
o.manager = manager
}
}
}
var observers: [any MediaPlayerObserver] = []
let baseItem: BaseItemDto
let mediaSource: MediaSourceInfo
let playSessionID: String
let previewImageProvider: (any PreviewImageProvider)?
let thumbnailProvider: ThumbnailProvider?
let url: URL
let audioStreams: [MediaStream]
let subtitleStreams: [MediaStream]
let videoStreams: [MediaStream]
let requestedBitrate: PlaybackBitrate
// MARK: init
init(
baseItem: BaseItemDto,
mediaSource: MediaSourceInfo,
playSessionID: String,
url: URL,
requestedBitrate: PlaybackBitrate = .max,
previewImageProvider: (any PreviewImageProvider)? = nil,
thumbnailProvider: ThumbnailProvider? = nil
) {
self.baseItem = baseItem
self.mediaSource = mediaSource
self.playSessionID = playSessionID
self.requestedBitrate = requestedBitrate
self.previewImageProvider = previewImageProvider
self.thumbnailProvider = thumbnailProvider
self.url = url
let adjustedMediaStreams = mediaSource.mediaStreams?.adjustedTrackIndexes(
for: mediaSource.transcodingURL == nil ? .directPlay : .transcode,
selectedAudioStreamIndex: mediaSource.defaultAudioStreamIndex ?? 0
)
let audioStreams = adjustedMediaStreams?.filter { $0.type == .audio } ?? []
let subtitleStreams = adjustedMediaStreams?.filter { $0.type == .subtitle } ?? []
let videoStreams = adjustedMediaStreams?.filter { $0.type == .video } ?? []
self.audioStreams = audioStreams
self.subtitleStreams = subtitleStreams
self.videoStreams = videoStreams
super.init()
selectedAudioStreamIndex = mediaSource.defaultAudioStreamIndex ?? -1
selectedSubtitleStreamIndex = mediaSource.defaultSubtitleStreamIndex ?? -1
observers.append(MediaProgressObserver(item: self))
}
}

View File

@ -1,240 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import AVFoundation
import Combine
import Defaults
import Foundation
import JellyfinAPI
import SwiftUI
// TODO: After NativeVideoPlayer is removed, can move bindings and
// observers to AVPlayerView, like the VLC delegate
// - wouldn't need to have MediaPlayerProxy: MediaPlayerObserver
// TODO: report playback information, see VLCUI.PlaybackInformation (dropped frames, etc.)
// TODO: report buffering state
// TODO: have set seconds with completion handler
@MainActor
class AVMediaPlayerProxy: VideoMediaPlayerProxy {
let isBuffering: PublishedBox<Bool> = .init(initialValue: false)
var isScrubbing: Binding<Bool> = .constant(false)
var scrubbedSeconds: Binding<Duration> = .constant(.zero)
var videoSize: PublishedBox<CGSize> = .init(initialValue: .zero)
let avPlayerLayer: AVPlayerLayer
let player: AVPlayer
// private var rateObserver: NSKeyValueObservation!
private var statusObserver: NSKeyValueObservation!
private var timeControlStatusObserver: NSKeyValueObservation!
private var timeObserver: Any!
private var managerItemObserver: AnyCancellable?
private var managerStateObserver: AnyCancellable?
weak var manager: MediaPlayerManager? {
didSet {
if let manager {
managerItemObserver = manager.$playbackItem
.sink { playbackItem in
if let playbackItem {
self.playNew(item: playbackItem)
}
}
managerStateObserver = manager.$state
.sink { state in
switch state {
case .stopped:
self.playbackStopped()
default: break
}
}
} else {
managerItemObserver?.cancel()
managerStateObserver?.cancel()
}
}
}
init() {
self.player = AVPlayer()
self.avPlayerLayer = AVPlayerLayer(player: player)
timeObserver = player.addPeriodicTimeObserver(
forInterval: CMTime(seconds: 1, preferredTimescale: 1000),
queue: .main
) { newTime in
let newSeconds = Duration.seconds(newTime.seconds)
if !self.isScrubbing.wrappedValue {
self.scrubbedSeconds.wrappedValue = newSeconds
}
self.manager?.seconds = newSeconds
}
}
func play() {
player.play()
}
func pause() {
player.pause()
}
func stop() {
player.pause()
}
func jumpForward(_ seconds: Duration) {
let currentTime = player.currentTime()
let newTime = currentTime + CMTime(seconds: seconds.seconds, preferredTimescale: 1)
player.seek(to: newTime, toleranceBefore: .zero, toleranceAfter: .zero)
}
func jumpBackward(_ seconds: Duration) {
let currentTime = player.currentTime()
let newTime = max(.zero, currentTime - CMTime(seconds: seconds.seconds, preferredTimescale: 1))
player.seek(to: newTime, toleranceBefore: .zero, toleranceAfter: .zero)
}
func setSeconds(_ seconds: Duration) {
let time = CMTime(seconds: seconds.seconds, preferredTimescale: 1)
player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero)
}
// TODO: complete
func setRate(_ rate: Float) {}
func setAudioStream(_ stream: MediaStream) {}
func setSubtitleStream(_ stream: MediaStream) {}
func setAspectFill(_ aspectFill: Bool) {
avPlayerLayer.videoGravity = aspectFill ? .resizeAspectFill : .resizeAspect
}
var videoPlayerBody: some View {
AVPlayerView()
.environmentObject(self)
}
}
extension AVMediaPlayerProxy {
private func playbackStopped() {
player.pause()
guard let timeObserver else { return }
player.removeTimeObserver(timeObserver)
// rateObserver.invalidate()
statusObserver.invalidate()
timeControlStatusObserver.invalidate()
}
private func playNew(item: MediaPlayerItem) {
let baseItem = item.baseItem
let newAVPlayerItem = AVPlayerItem(url: item.url)
newAVPlayerItem.externalMetadata = item.baseItem.avMetadata
player.replaceCurrentItem(with: newAVPlayerItem)
// TODO: protect against paused
// rateObserver = player.observe(\.rate, options: [.new, .initial]) { _, value in
// DispatchQueue.main.async {
// self.manager?.set(rate: value.newValue ?? 1.0)
// }
// }
timeControlStatusObserver = player.observe(\.timeControlStatus, options: [.new, .initial]) { player, _ in
let timeControlStatus = player.timeControlStatus
DispatchQueue.main.async {
switch timeControlStatus {
case .paused:
self.manager?.setPlaybackRequestStatus(status: .paused)
case .waitingToPlayAtSpecifiedRate: ()
// TODO: buffering
case .playing:
self.manager?.setPlaybackRequestStatus(status: .playing)
@unknown default: ()
}
}
}
// TODO: proper handling of none/unknown states
statusObserver = player.observe(\.currentItem?.status, options: [.new, .initial]) { _, value in
guard let newValue = value.newValue else { return }
switch newValue {
case .failed:
if let error = self.player.error {
DispatchQueue.main.async {
self.manager?.error(JellyfinAPIError("AVPlayer error: \(error.localizedDescription)"))
}
}
case .none, .readyToPlay, .unknown:
let startSeconds = max(.zero, (baseItem.startSeconds ?? .zero) - Duration.seconds(Defaults[.VideoPlayer.resumeOffset]))
self.player.seek(
to: CMTimeMake(
value: startSeconds.components.seconds,
timescale: 1
),
toleranceBefore: .zero,
toleranceAfter: .zero,
completionHandler: { _ in
self.play()
}
)
@unknown default: ()
}
}
}
}
// MARK: - AVPlayerView
extension AVMediaPlayerProxy {
struct AVPlayerView: UIViewRepresentable {
@EnvironmentObject
private var proxy: AVMediaPlayerProxy
@EnvironmentObject
private var scrubbedSeconds: PublishedBox<Duration>
func makeUIView(context: Context) -> UIView {
// proxy.isScrubbing = context.environment.isScrubbing
// proxy.scrubbedSeconds = $scrubbedSeconds.value
UIAVPlayerView(proxy: proxy)
}
func updateUIView(_ uiView: UIView, context: Context) {}
}
private class UIAVPlayerView: UIView {
let proxy: AVMediaPlayerProxy
init(proxy: AVMediaPlayerProxy) {
self.proxy = proxy
super.init(frame: .zero)
layer.addSublayer(proxy.avPlayerLayer)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
proxy.avPlayerLayer.frame = bounds
}
}
}

View File

@ -1,230 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Defaults
import Foundation
import JellyfinAPI
import SwiftUI
import VLCUI
class VLCMediaPlayerProxy: VideoMediaPlayerProxy,
MediaPlayerOffsetConfigurable,
MediaPlayerSubtitleConfigurable
{
let isBuffering: PublishedBox<Bool> = .init(initialValue: false)
let videoSize: PublishedBox<CGSize> = .init(initialValue: .zero)
let vlcUIProxy: VLCVideoPlayer.Proxy = .init()
weak var manager: MediaPlayerManager? {
didSet {
for var o in observers {
o.manager = manager
}
}
}
var observers: [any MediaPlayerObserver] = [
NowPlayableObserver(),
]
func play() {
vlcUIProxy.play()
}
func pause() {
vlcUIProxy.pause()
}
func stop() {
vlcUIProxy.stop()
}
func jumpForward(_ seconds: Duration) {
vlcUIProxy.jumpForward(seconds)
}
func jumpBackward(_ seconds: Duration) {
vlcUIProxy.jumpBackward(seconds)
}
func setRate(_ rate: Float) {
vlcUIProxy.setRate(.absolute(rate))
}
func setSeconds(_ seconds: Duration) {
vlcUIProxy.setSeconds(seconds)
}
func setAudioStream(_ stream: MediaStream) {
vlcUIProxy.setAudioTrack(.absolute(stream.index ?? -1))
}
func setSubtitleStream(_ stream: MediaStream) {
vlcUIProxy.setSubtitleTrack(.absolute(stream.index ?? -1))
}
func setAspectFill(_ aspectFill: Bool) {
vlcUIProxy.aspectFill(aspectFill ? 1 : 0)
}
func setAudioOffset(_ seconds: Duration) {
vlcUIProxy.setAudioDelay(seconds)
}
func setSubtitleOffset(_ seconds: Duration) {
vlcUIProxy.setSubtitleDelay(seconds)
}
func setSubtitleColor(_ color: Color) {
vlcUIProxy.setSubtitleColor(.absolute(color.uiColor))
}
func setSubtitleFontName(_ fontName: String) {
vlcUIProxy.setSubtitleFont(fontName)
}
func setSubtitleFontSize(_ fontSize: Int) {
vlcUIProxy.setSubtitleSize(.absolute(fontSize))
}
@ViewBuilder
var videoPlayerBody: some View {
VLCPlayerView()
.environmentObject(vlcUIProxy)
}
}
extension VLCMediaPlayerProxy {
struct VLCPlayerView: View {
@Default(.VideoPlayer.Subtitle.subtitleColor)
private var subtitleColor
@Default(.VideoPlayer.Subtitle.subtitleFontName)
private var subtitleFontName
@Default(.VideoPlayer.Subtitle.subtitleSize)
private var subtitleSize
@EnvironmentObject
private var containerState: VideoPlayerContainerState
@EnvironmentObject
private var manager: MediaPlayerManager
@EnvironmentObject
private var proxy: VLCVideoPlayer.Proxy
private var isScrubbing: Bool {
containerState.isScrubbing
}
private func vlcConfiguration(for item: MediaPlayerItem) -> VLCVideoPlayer.Configuration {
let baseItem = item.baseItem
let mediaSource = item.mediaSource
var configuration = VLCVideoPlayer.Configuration(url: item.url)
configuration.autoPlay = true
let startSeconds = max(.zero, (baseItem.startSeconds ?? .zero) - Duration.seconds(Defaults[.VideoPlayer.resumeOffset]))
if !baseItem.isLiveStream {
configuration.startSeconds = startSeconds
configuration.audioIndex = .absolute(mediaSource.defaultAudioStreamIndex ?? -1)
configuration.subtitleIndex = .absolute(mediaSource.defaultSubtitleStreamIndex ?? -1)
}
configuration.subtitleSize = .absolute(25 - Defaults[.VideoPlayer.Subtitle.subtitleSize])
configuration.subtitleColor = .absolute(Defaults[.VideoPlayer.Subtitle.subtitleColor].uiColor)
if let font = UIFont(name: Defaults[.VideoPlayer.Subtitle.subtitleFontName], size: 1) {
configuration.subtitleFont = .absolute(font)
}
configuration.playbackChildren = item.subtitleStreams
.filter { $0.deliveryMethod == .external }
.compactMap(\.asVLCPlaybackChild)
return configuration
}
var body: some View {
if let playbackItem = manager.playbackItem, manager.state != .stopped {
VLCVideoPlayer(configuration: vlcConfiguration(for: playbackItem))
.proxy(proxy)
.onSecondsUpdated { newSeconds, info in
if !isScrubbing {
containerState.scrubbedSeconds.value = newSeconds
}
manager.seconds = newSeconds
if let proxy = manager.proxy as? any VideoMediaPlayerProxy {
proxy.videoSize.value = info.videoSize
}
}
.onStateUpdated { state, info in
manager.logger.trace("VLC state updated: \(state)")
switch state {
case .buffering,
.esAdded,
.opening:
// TODO: figure out when to properly set to false
manager.proxy?.isBuffering.value = true
case .ended:
// Live streams will send stopped/ended events
guard !playbackItem.baseItem.isLiveStream else { return }
manager.proxy?.isBuffering.value = false
manager.ended()
case .stopped: ()
// Stopped is ignored as the `MediaPlayerManager`
// should instead call this to be stopped, rather
// than react to the event.
case .error:
manager.proxy?.isBuffering.value = false
manager.error(JellyfinAPIError("VLC player is unable to perform playback"))
case .playing:
manager.proxy?.isBuffering.value = false
manager.setPlaybackRequestStatus(status: .playing)
case .paused:
manager.setPlaybackRequestStatus(status: .paused)
}
if let proxy = manager.proxy as? any VideoMediaPlayerProxy {
proxy.videoSize.value = info.videoSize
}
}
.onReceive(manager.$playbackItem) { playbackItem in
guard let playbackItem else { return }
proxy.playNewMedia(vlcConfiguration(for: playbackItem))
}
.backport
.onChange(of: manager.rate) { _, newValue in
proxy.setRate(.absolute(newValue))
}
.backport
.onChange(of: subtitleColor) { _, newValue in
if let proxy = proxy as? MediaPlayerSubtitleConfigurable {
proxy.setSubtitleColor(newValue)
}
}
.backport
.onChange(of: subtitleFontName) { _, newValue in
if let proxy = proxy as? MediaPlayerSubtitleConfigurable {
proxy.setSubtitleFontName(newValue)
}
}
.backport
.onChange(of: subtitleSize) { _, newValue in
if let proxy = proxy as? MediaPlayerSubtitleConfigurable {
proxy.setSubtitleFontSize(25 - newValue)
}
}
}
}
}
}

View File

@ -1,59 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Foundation
import JellyfinAPI
import SwiftUI
// TODO: feature implementations
// - PiP
// TODO: Chromecast proxy
/// The proxy for top-down communication to an
/// underlying media player
protocol MediaPlayerProxy: ObservableObject, MediaPlayerObserver {
var isBuffering: PublishedBox<Bool> { get }
func play()
func pause()
func stop()
func jumpForward(_ seconds: Duration)
func jumpBackward(_ seconds: Duration)
func setRate(_ rate: Float)
func setSeconds(_ seconds: Duration)
}
@MainActor
protocol VideoMediaPlayerProxy: MediaPlayerProxy {
associatedtype VideoPlayerBody: View
var videoSize: PublishedBox<CGSize> { get }
// TODO: remove when container view handles aspect fill
func setAspectFill(_ aspectFill: Bool)
func setAudioStream(_ stream: MediaStream)
func setSubtitleStream(_ stream: MediaStream)
@ViewBuilder
@MainActor
var videoPlayerBody: Self.VideoPlayerBody { get }
}
protocol MediaPlayerOffsetConfigurable {
func setAudioOffset(_ seconds: Duration)
func setSubtitleOffset(_ seconds: Duration)
}
protocol MediaPlayerSubtitleConfigurable {
func setSubtitleColor(_ color: Color)
func setSubtitleFontName(_ fontName: String)
func setSubtitleFontSize(_ fontSize: Int)
}

View File

@ -1,115 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Foundation
import MediaPlayer
enum NowPlayableCommand: CaseIterable {
// Play/Pause
case pause
case play
case stop
case togglePausePlay
// Track
case nextTrack
case previousTrack
case changeRepeatMode
case changeShuffleMode
// Seeking/Rate
case changePlaybackRate
case seekBackward
case seekForward
case skipBackward
case skipForward
case changePlaybackPosition
// Like/Dislike
case rating
case like
case dislike
// Bookmark
case bookmark
// Languages
case enableLanguageOption
case disableLanguageOption
var remoteCommand: MPRemoteCommand {
let remoteCommandCenter = MPRemoteCommandCenter.shared()
switch self {
case .pause:
return remoteCommandCenter.pauseCommand
case .play:
return remoteCommandCenter.playCommand
case .stop:
return remoteCommandCenter.stopCommand
case .togglePausePlay:
return remoteCommandCenter.togglePlayPauseCommand
case .nextTrack:
return remoteCommandCenter.nextTrackCommand
case .previousTrack:
return remoteCommandCenter.previousTrackCommand
case .changeRepeatMode:
return remoteCommandCenter.changeRepeatModeCommand
case .changeShuffleMode:
return remoteCommandCenter.changeShuffleModeCommand
case .changePlaybackRate:
return remoteCommandCenter.changePlaybackRateCommand
case .seekBackward:
return remoteCommandCenter.seekBackwardCommand
case .seekForward:
return remoteCommandCenter.seekForwardCommand
case .skipBackward:
return remoteCommandCenter.skipBackwardCommand
case .skipForward:
return remoteCommandCenter.skipForwardCommand
case .changePlaybackPosition:
return remoteCommandCenter.changePlaybackPositionCommand
case .rating:
return remoteCommandCenter.ratingCommand
case .like:
return remoteCommandCenter.likeCommand
case .dislike:
return remoteCommandCenter.dislikeCommand
case .bookmark:
return remoteCommandCenter.bookmarkCommand
case .enableLanguageOption:
return remoteCommandCenter.enableLanguageOptionCommand
case .disableLanguageOption:
return remoteCommandCenter.disableLanguageOptionCommand
}
}
func removeHandler() {
remoteCommand.removeTarget(nil)
}
func addHandler(_ handler: @escaping (NowPlayableCommand, MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus) {
remoteCommand.removeTarget(nil)
switch self {
case .skipBackward:
MPRemoteCommandCenter.shared().skipBackwardCommand.preferredIntervals = [15.0]
case .skipForward:
MPRemoteCommandCenter.shared().skipForwardCommand.preferredIntervals = [15.0]
default: ()
}
remoteCommand.addTarget { handler(self, $0) }
}
func isEnabled(_ isEnabled: Bool) {
remoteCommand.isEnabled = isEnabled
}
}

View File

@ -1,65 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Foundation
import MediaPlayer
struct NowPlayableStaticMetadata {
let mediaType: MPNowPlayingInfoMediaType
let isLiveStream: Bool
let title: String
let artist: String?
let artwork: MPMediaItemArtwork?
let albumArtist: String?
let albumTitle: String?
init(
mediaType: MPNowPlayingInfoMediaType,
isLiveStream: Bool = false,
title: String,
artist: String? = nil,
artwork: MPMediaItemArtwork? = nil,
albumArtist: String? = nil,
albumTitle: String? = nil
) {
self.mediaType = mediaType
self.isLiveStream = isLiveStream
self.title = title
self.artist = artist
self.artwork = artwork
self.albumArtist = albumArtist
self.albumTitle = albumTitle
}
}
struct NowPlayableDynamicMetadata {
let rate: Float
let position: Duration
let duration: Duration
let currentLanguageOptions: [MPNowPlayingInfoLanguageOption]
let availableLanguageOptionGroups: [MPNowPlayingInfoLanguageOptionGroup]
init(
rate: Float = 1,
position: Duration,
duration: Duration,
currentLanguageOptions: [MPNowPlayingInfoLanguageOption] = [],
availableLanguageOptionGroups: [MPNowPlayingInfoLanguageOptionGroup] = []
) {
self.rate = rate
self.position = position
self.duration = duration
self.currentLanguageOptions = currentLanguageOptions
self.availableLanguageOptionGroups = availableLanguageOptionGroups
}
}

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