Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Shared/Components/NativeVideoPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ struct NativeVideoPlayer: View {
switch manager.state {
case .playback:
NativeVideoPlayerView(proxy: proxy)
.overlay(alignment: .bottomTrailing) {
SkipSegmentButton()
.padding(.trailing, 80)
.padding(.bottom, 120)
}
default:
ProgressView()
}
Expand Down
38 changes: 38 additions & 0 deletions Shared/Components/SkipSegmentButton.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//

import Factory
import JellyfinAPI
import SwiftUI

struct SkipSegmentButton: View {

@InjectedObject(\.mediaPlayerManager)
private var manager: MediaPlayerManager

var body: some View {
if let segment = manager.currentSegment, let type = segment.type {
Button(action: {
manager.skipCurrentSegment()
}) {
Label(L10n.skipSegment(type.displayTitle), systemImage: "forward.end.fill")
.fontWeight(.semibold)
.padding(10)
.background(.thinMaterial)
.clipShape(Capsule())
.overlay(
Capsule()
.strokeBorder(Color.white.opacity(0.2), lineWidth: 1)
)
}
.buttonStyle(.plain)
.transition(.opacity)
.animation(.spring(), value: manager.currentSegment)
}
}
}
31 changes: 31 additions & 0 deletions Shared/Extensions/JellyfinAPI/MediaSegmentDto.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//

import Defaults
import Foundation
import JellyfinAPI

extension MediaSegmentType: Displayable, _DefaultsSerializable {

var displayTitle: String {
switch self {
case .commercial:
return L10n.commercial
case .preview:
return L10n.preview
case .recap:
return L10n.recap
case .outro:
return L10n.outro
case .intro:
return L10n.intro
case .unknown:
return L10n.unknown
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import JellyfinAPI
typealias ItemSortOrder = JellyfinAPI.SortOrder

extension ItemSortOrder: Displayable {
// TODO: Localize
var displayTitle: String {
switch self {
case .ascending:
Expand Down
1 change: 0 additions & 1 deletion Shared/Objects/LibraryDisplayType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ enum LibraryDisplayType: String, CaseIterable, Displayable, Storable, SystemImag
case grid
case list

// TODO: localize
var displayTitle: String {
switch self {
case .grid:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ extension MediaPlayerItem {
return nil
}()

return .init(
let mediaPlayerItem = MediaPlayerItem(
baseItem: item,
mediaSource: mediaSource,
playSessionID: playSessionID,
Expand All @@ -165,6 +165,24 @@ extension MediaPlayerItem {
previewImageProvider: previewImageProvider,
thumbnailProvider: item.getNowPlayingImage
)

Task {
do {
let request = Paths.getItemSegments(itemID: itemID)
let response = try await userSession.client.send(request)
let items = response.value.items ?? []
if !items.isEmpty {
await MainActor.run {
mediaPlayerItem.mediaSegments = items
logger.info("Media segments fetched: \(items)")
}
}
} catch {
logger.error("Failed to fetch media segments: \(error)")
}
}

return mediaPlayerItem
}

// TODO: audio type stream
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ class MediaPlayerItem: ViewModel, MediaPlayerObserver {

let requestedBitrate: PlaybackBitrate

var mediaSegments: [MediaSegmentDto] = []

// MARK: init

init(
Expand Down
70 changes: 61 additions & 9 deletions Shared/Objects/MediaPlayerManager/MediaPlayerManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import Defaults
import Factory
import Foundation
import JellyfinAPI
import StatefulMacros
import VLCUI

// TODO: proper error catching
Expand Down Expand Up @@ -43,8 +44,6 @@ extension Container {
}
}

import StatefulMacros

@MainActor
@Stateful
final class MediaPlayerManager: ViewModel {
Expand Down Expand Up @@ -126,6 +125,8 @@ final class MediaPlayerManager: ViewModel {
var rate: Float = 1.0
@Published
var queue: AnyMediaPlayerQueue? = nil
@Published
var currentSegment: MediaSegmentDto?

@Published
var supplements: [any MediaPlayerSupplement] = []
Expand Down Expand Up @@ -156,7 +157,10 @@ final class MediaPlayerManager: ViewModel {

var seconds: Duration {
get { secondsBox.value }
set { secondsBox.value = newValue }
set {
secondsBox.value = newValue
updateCurrentSegment()
}
}

/// Holds a weak reference to the current media player proxy.
Expand All @@ -174,13 +178,13 @@ final class MediaPlayerManager: ViewModel {

// MARK: init

// static let empty: MediaPlayerManager = .init()
// static let empty: MediaPlayerManager = .init()

// override private init() {
// self.item = .init()
// self.state = .stopped
// super.init()
// }
// override private init() {
// self.item = .init()
// self.state = .stopped
// super.init()
// }

init(
item: BaseItemDto,
Expand Down Expand Up @@ -322,4 +326,52 @@ final class MediaPlayerManager: ViewModel {
setPlaybackRequestStatus(status: .playing)
}
}

private func updateCurrentSegment() {
guard Defaults[.VideoPlayer.enableMediaSegments] else {
if currentSegment != nil { currentSegment = nil }
return
}

guard let segments = playbackItem?.mediaSegments, !segments.isEmpty else {
if currentSegment != nil { currentSegment = nil }
return
}

let found = segments.first { segment in
guard let start = segment.startTicks,
let end = segment.endTicks,
let type = segment.type
else {
return false
}

let isWithinRange = seconds.ticks >= start && seconds.ticks < end

if isWithinRange && Defaults[.VideoPlayer.skipMediaSegments].contains(type) {
// TODO: autoskip doesn't work very well with Swiftfin player, only with native
self.skipSegment(segment)
return false
}

return isWithinRange && Defaults[.VideoPlayer.askMediaSegments].contains(type)
}

if currentSegment != found {
currentSegment = found
}
}

func skipSegment(_ segment: MediaSegmentDto) {
guard let endTicks = segment.endTicks else { return }
let endSeconds = Double(endTicks) / 10_000_000
let newDuration = Duration.seconds(endSeconds)
self.seconds = newDuration
self.proxy?.setSeconds(newDuration)
}

func skipCurrentSegment() {
guard let currentSegment else { return }
skipSegment(currentSegment)
}
}
29 changes: 29 additions & 0 deletions Shared/Objects/MediaSegmentBehavior.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the 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 MediaSegmentBehavior: String, CaseIterable, Identifiable, Displayable {

case off
case ask
case skip

var id: String {
self.rawValue
}

var displayTitle: String {
switch self {
case .ask:
L10n.ask
case .off:
L10n.off
case .skip:
L10n.skip
}
}
}
5 changes: 5 additions & 0 deletions Shared/Services/SwiftfinDefaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import Defaults
import Factory
import Foundation
import JellyfinAPI
import SwiftUI
import UIKit

Expand Down Expand Up @@ -235,6 +236,10 @@ extension Defaults.Keys {
enum Transition {
static let pauseOnBackground: Key<Bool> = UserKey("playInBackground", default: true)
}

static let enableMediaSegments: Key<Bool> = UserKey("enableMediaSegments", default: true)
static let askMediaSegments: Key<[MediaSegmentType]> = UserKey("askMediaSegments", default: [])
static let skipMediaSegments: Key<[MediaSegmentType]> = UserKey("skipMediaSegments", default: [])
}

// Experimental settings
Expand Down
24 changes: 24 additions & 0 deletions Shared/Strings/Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ internal enum L10n {
internal static let artists = L10n.tr("Localizable", "artists", fallback: "Artists")
/// Ascending
internal static let ascending = L10n.tr("Localizable", "ascending", fallback: "Ascending")
/// Ask
internal static let ask = L10n.tr("Localizable", "ask", fallback: "Ask")
/// Aspect fill
internal static let aspectFill = L10n.tr("Localizable", "aspectFill", fallback: "Aspect fill")
/// Audio
Expand Down Expand Up @@ -320,6 +322,8 @@ internal enum L10n {
internal static func columnsWithCount(_ p1: Any) -> String {
return L10n.tr("Localizable", "columnsWithCount", String(describing: p1), fallback: "Columns: %@")
}
/// Commercial
internal static let commercial = L10n.tr("Localizable", "commercial", fallback: "Commercial")
/// Community
internal static let community = L10n.tr("Localizable", "community", fallback: "Community")
/// Community rating
Expand Down Expand Up @@ -586,6 +590,8 @@ internal enum L10n {
internal static let enabled = L10n.tr("Localizable", "enabled", fallback: "Enabled")
/// Enabled trailers
internal static let enabledTrailers = L10n.tr("Localizable", "enabledTrailers", fallback: "Enabled trailers")
/// Enable media segments
internal static let enableMediaSegments = L10n.tr("Localizable", "enableMediaSegments", fallback: "Enable media segments")
/// End date
internal static let endDate = L10n.tr("Localizable", "endDate", fallback: "End date")
/// Ended
Expand Down Expand Up @@ -758,6 +764,8 @@ internal enum L10n {
internal static let interval = L10n.tr("Localizable", "interval", fallback: "Interval")
/// Interview
internal static let interview = L10n.tr("Localizable", "interview", fallback: "Interview")
/// Intro
internal static let intro = L10n.tr("Localizable", "intro", fallback: "Intro")
/// Invalid format
internal static let invalidFormat = L10n.tr("Localizable", "invalidFormat", fallback: "Invalid format")
/// Inverted dark
Expand Down Expand Up @@ -918,6 +926,8 @@ internal enum L10n {
internal static let mediaDownloads = L10n.tr("Localizable", "mediaDownloads", fallback: "Media downloads")
/// Media playback
internal static let mediaPlayback = L10n.tr("Localizable", "mediaPlayback", fallback: "Media playback")
/// Media segments
internal static let mediaSegments = L10n.tr("Localizable", "mediaSegments", fallback: "Media segments")
/// Mbps
internal static let megabitsPerSecond = L10n.tr("Localizable", "megabitsPerSecond", fallback: "Mbps")
/// Menu
Expand Down Expand Up @@ -1012,6 +1022,8 @@ internal enum L10n {
internal static func notImplementedYetWithType(_ p1: Any) -> String {
return L10n.tr("Localizable", "notImplementedYetWithType", String(describing: p1), fallback: "Type: %@ not implemented yet :(")
}
/// Off
internal static let off = L10n.tr("Localizable", "off", fallback: "Off")
/// Official rating
internal static let officialRating = L10n.tr("Localizable", "officialRating", fallback: "Official rating")
/// Offset
Expand Down Expand Up @@ -1040,6 +1052,8 @@ internal enum L10n {
internal static let other = L10n.tr("Localizable", "other", fallback: "Other")
/// Out of date
internal static let outOfDate = L10n.tr("Localizable", "outOfDate", fallback: "Out of date")
/// Outro
internal static let outro = L10n.tr("Localizable", "outro", fallback: "Outro")
/// Overview
internal static let overview = L10n.tr("Localizable", "overview", fallback: "Overview")
/// Parental controls
Expand Down Expand Up @@ -1116,6 +1130,8 @@ internal enum L10n {
internal static let posters = L10n.tr("Localizable", "posters", fallback: "Posters")
/// Premiere date
internal static let premiereDate = L10n.tr("Localizable", "premiereDate", fallback: "Premiere date")
/// Preview
internal static let preview = L10n.tr("Localizable", "preview", fallback: "Preview")
/// Preview image
internal static let previewImage = L10n.tr("Localizable", "previewImage", fallback: "Preview image")
/// Previous item
Expand Down Expand Up @@ -1172,6 +1188,8 @@ internal enum L10n {
internal static let rating = L10n.tr("Localizable", "rating", fallback: "Rating")
/// Ratings
internal static let ratings = L10n.tr("Localizable", "ratings", fallback: "Ratings")
/// Recap
internal static let recap = L10n.tr("Localizable", "recap", fallback: "Recap")
/// Recently Added
internal static let recentlyAdded = L10n.tr("Localizable", "recentlyAdded", fallback: "Recently Added")
/// Recommended
Expand Down Expand Up @@ -1398,6 +1416,12 @@ internal enum L10n {
internal static let signoutClose = L10n.tr("Localizable", "signoutClose", fallback: "Sign out on close")
/// Signs out the last user when Swiftfin has been force closed.
internal static let signoutCloseFooter = L10n.tr("Localizable", "signoutCloseFooter", fallback: "Signs out the last user when Swiftfin has been force closed.")
/// Skip
internal static let skip = L10n.tr("Localizable", "skip", fallback: "Skip")
/// Skip %@
internal static func skipSegment(_ p1: Any) -> String {
return L10n.tr("Localizable", "skipSegment", String(describing: p1), fallback: "Skip %@")
}
/// Slider
internal static let slider = L10n.tr("Localizable", "slider", fallback: "Slider")
/// Slow scrub
Expand Down
Loading