649 lines
23 KiB
Swift
649 lines
23 KiB
Swift
//
|
|
// Swiftfin is subject to the terms of the Mozilla Public
|
|
// License, v2.0. If a copy of the MPL was not distributed with this
|
|
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
//
|
|
// Copyright (c) 2022 Jellyfin & Jellyfin Contributors
|
|
//
|
|
|
|
import Algorithms
|
|
import Combine
|
|
import Defaults
|
|
import Foundation
|
|
import JellyfinAPI
|
|
import UIKit
|
|
|
|
#if os(tvOS)
|
|
import TVVLCKit
|
|
#else
|
|
import MobileVLCKit
|
|
#endif
|
|
|
|
final class VideoPlayerViewModel: ViewModel {
|
|
// MARK: Published
|
|
|
|
// Manually kept state because VLCKit doesn't properly set "played"
|
|
// on the VLCMediaPlayer object
|
|
@Published
|
|
var playerState: VLCMediaPlayerState = .buffering
|
|
@Published
|
|
var leftLabelText: String = "--:--"
|
|
@Published
|
|
var rightLabelText: String = "--:--"
|
|
@Published
|
|
var scrubbingTimeLabelText: String = "--:--"
|
|
@Published
|
|
var playbackSpeed: PlaybackSpeed = .one
|
|
@Published
|
|
var subtitlesEnabled: Bool {
|
|
didSet {
|
|
if syncSubtitleStateWithAdjacent {
|
|
previousItemVideoPlayerViewModel?.matchSubtitlesEnabled(with: self)
|
|
nextItemVideoPlayerViewModel?.matchSubtitlesEnabled(with: self)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Published
|
|
var selectedAudioStreamIndex: Int
|
|
@Published
|
|
var selectedSubtitleStreamIndex: Int {
|
|
didSet {
|
|
if syncSubtitleStateWithAdjacent {
|
|
previousItemVideoPlayerViewModel?.matchSubtitleStream(with: self)
|
|
nextItemVideoPlayerViewModel?.matchSubtitleStream(with: self)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Published
|
|
var previousItemVideoPlayerViewModel: VideoPlayerViewModel?
|
|
@Published
|
|
var nextItemVideoPlayerViewModel: VideoPlayerViewModel?
|
|
@Published
|
|
var jumpBackwardLength: VideoPlayerJumpLength {
|
|
willSet {
|
|
Defaults[.videoPlayerJumpBackward] = newValue
|
|
}
|
|
}
|
|
|
|
@Published
|
|
var jumpForwardLength: VideoPlayerJumpLength {
|
|
willSet {
|
|
Defaults[.videoPlayerJumpForward] = newValue
|
|
}
|
|
}
|
|
|
|
@Published
|
|
var isHiddenCenterViews = false
|
|
|
|
@Published
|
|
var sliderIsScrubbing: Bool = false {
|
|
didSet {
|
|
isHiddenCenterViews = sliderIsScrubbing
|
|
beganScrubbingCurrentSeconds = currentSeconds
|
|
}
|
|
}
|
|
|
|
@Published
|
|
var sliderPercentage: Double = 0 {
|
|
willSet {
|
|
sliderScrubbingSubject.send(self)
|
|
sliderPercentageChanged(newValue: newValue)
|
|
}
|
|
}
|
|
|
|
@Published
|
|
var autoplayEnabled: Bool {
|
|
willSet {
|
|
previousItemVideoPlayerViewModel?.autoplayEnabled = newValue
|
|
nextItemVideoPlayerViewModel?.autoplayEnabled = newValue
|
|
Defaults[.autoplayEnabled] = newValue
|
|
}
|
|
}
|
|
|
|
@Published
|
|
var mediaItems: [BaseItemDto.ItemDetail]
|
|
|
|
@Published
|
|
var isHiddenOverlay = false
|
|
|
|
// MARK: ShouldShowItems
|
|
|
|
let shouldShowPlayPreviousItem: Bool
|
|
let shouldShowPlayNextItem: Bool
|
|
let shouldShowAutoPlay: Bool
|
|
let shouldShowJumpButtonsInOverlayMenu: Bool
|
|
|
|
// MARK: General
|
|
|
|
private(set) var item: BaseItemDto
|
|
let title: String
|
|
let subtitle: String?
|
|
let directStreamURL: URL
|
|
let transcodedStreamURL: URL?
|
|
let hlsStreamURL: URL
|
|
let audioStreams: [MediaStream]
|
|
let subtitleStreams: [MediaStream]
|
|
let chapters: [ChapterInfo]
|
|
let overlayType: OverlayType
|
|
let jumpGesturesEnabled: Bool
|
|
let systemControlGesturesEnabled: Bool
|
|
let seekSlideGestureEnabled: Bool
|
|
let playerGesturesLockGestureEnabled: Bool
|
|
let resumeOffset: Bool
|
|
let streamType: ServerStreamType
|
|
let container: String
|
|
let filename: String?
|
|
let versionName: String?
|
|
|
|
// MARK: Experimental
|
|
|
|
let syncSubtitleStateWithAdjacent: Bool
|
|
|
|
// MARK: tvOS
|
|
|
|
let confirmClose: Bool
|
|
|
|
// Full response kept for convenience
|
|
let response: PlaybackInfoResponse
|
|
|
|
var playerOverlayDelegate: PlayerOverlayDelegate?
|
|
|
|
// Ticks of the time the media began playing
|
|
private var startTimeTicks: Int64 = 0
|
|
|
|
// MARK: Current Time
|
|
|
|
private var beganScrubbingCurrentSeconds: Double = 0
|
|
|
|
var currentSeconds: Double {
|
|
let runTimeTicks = item.runTimeTicks ?? 0
|
|
let videoDuration = Double(runTimeTicks / 10_000_000)
|
|
return round(sliderPercentage * videoDuration)
|
|
}
|
|
|
|
var currentSecondTicks: Int64 {
|
|
Int64(currentSeconds) * 10_000_000
|
|
}
|
|
|
|
func setSeconds(_ seconds: Int64) {
|
|
guard let runTimeTicks = item.runTimeTicks else { return }
|
|
let videoDuration = runTimeTicks
|
|
let percentage = Double(seconds * 10_000_000) / Double(videoDuration)
|
|
|
|
sliderPercentage = percentage
|
|
}
|
|
|
|
// MARK: Helpers
|
|
|
|
var currentAudioStream: MediaStream? {
|
|
audioStreams.first(where: { $0.index == selectedAudioStreamIndex })
|
|
}
|
|
|
|
var currentSubtitleStream: MediaStream? {
|
|
subtitleStreams.first(where: { $0.index == selectedSubtitleStreamIndex })
|
|
}
|
|
|
|
var currentChapter: ChapterInfo? {
|
|
let chapterPairs = chapters.adjacentPairs().map { ($0, $1) }
|
|
let chapterRanges = chapterPairs.map { ($0.startPositionTicks ?? 0, ($1.startPositionTicks ?? 1) - 1) }
|
|
|
|
for chapterRangeIndex in 0 ..< chapterRanges.count {
|
|
if chapterRanges[chapterRangeIndex].0 <= currentSecondTicks,
|
|
currentSecondTicks < chapterRanges[chapterRangeIndex].1
|
|
{
|
|
return chapterPairs[chapterRangeIndex].0
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Necessary PassthroughSubject to capture manual scrubbing from sliders
|
|
let sliderScrubbingSubject = PassthroughSubject<VideoPlayerViewModel, Never>()
|
|
|
|
// During scrubbing, many progress reports were spammed
|
|
// Send only the current report after a delay
|
|
private var progressReportTimer: Timer?
|
|
private var lastProgressReport: ReportPlaybackProgressRequest?
|
|
|
|
// MARK: init
|
|
|
|
init(item: BaseItemDto,
|
|
title: String,
|
|
subtitle: String?,
|
|
directStreamURL: URL,
|
|
transcodedStreamURL: URL?,
|
|
hlsStreamURL: URL,
|
|
streamType: ServerStreamType,
|
|
response: PlaybackInfoResponse,
|
|
audioStreams: [MediaStream],
|
|
subtitleStreams: [MediaStream],
|
|
chapters: [ChapterInfo],
|
|
selectedAudioStreamIndex: Int,
|
|
selectedSubtitleStreamIndex: Int,
|
|
subtitlesEnabled: Bool,
|
|
autoplayEnabled: Bool,
|
|
overlayType: OverlayType,
|
|
shouldShowPlayPreviousItem: Bool,
|
|
shouldShowPlayNextItem: Bool,
|
|
shouldShowAutoPlay: Bool,
|
|
container: String,
|
|
filename: String?,
|
|
versionName: String?)
|
|
{
|
|
self.item = item
|
|
self.title = title
|
|
self.subtitle = subtitle
|
|
self.directStreamURL = directStreamURL
|
|
self.transcodedStreamURL = transcodedStreamURL
|
|
self.hlsStreamURL = hlsStreamURL
|
|
self.streamType = streamType
|
|
self.response = response
|
|
self.audioStreams = audioStreams
|
|
self.subtitleStreams = subtitleStreams
|
|
self.chapters = chapters
|
|
self.selectedAudioStreamIndex = selectedAudioStreamIndex
|
|
self.selectedSubtitleStreamIndex = selectedSubtitleStreamIndex
|
|
self.subtitlesEnabled = subtitlesEnabled
|
|
self.autoplayEnabled = autoplayEnabled
|
|
self.overlayType = overlayType
|
|
self.shouldShowPlayPreviousItem = shouldShowPlayPreviousItem
|
|
self.shouldShowPlayNextItem = shouldShowPlayNextItem
|
|
self.shouldShowAutoPlay = shouldShowAutoPlay
|
|
self.container = container
|
|
self.filename = filename
|
|
self.versionName = versionName
|
|
|
|
self.jumpBackwardLength = Defaults[.videoPlayerJumpBackward]
|
|
self.jumpForwardLength = Defaults[.videoPlayerJumpForward]
|
|
self.jumpGesturesEnabled = Defaults[.jumpGesturesEnabled]
|
|
self.systemControlGesturesEnabled = Defaults[.systemControlGesturesEnabled]
|
|
self.playerGesturesLockGestureEnabled = Defaults[.playerGesturesLockGestureEnabled]
|
|
self.seekSlideGestureEnabled = Defaults[.seekSlideGestureEnabled]
|
|
self.shouldShowJumpButtonsInOverlayMenu = Defaults[.shouldShowJumpButtonsInOverlayMenu]
|
|
|
|
self.resumeOffset = Defaults[.resumeOffset]
|
|
|
|
self.syncSubtitleStateWithAdjacent = Defaults[.Experimental.syncSubtitleStateWithAdjacent]
|
|
|
|
self.confirmClose = Defaults[.confirmClose]
|
|
|
|
self.mediaItems = item.createMediaItems()
|
|
|
|
super.init()
|
|
|
|
self.sliderPercentage = (item.userData?.playedPercentage ?? 0) / 100
|
|
}
|
|
|
|
private func sliderPercentageChanged(newValue: Double) {
|
|
let runTimeTicks = item.runTimeTicks ?? 0
|
|
let videoDuration = Double(runTimeTicks / 10_000_000)
|
|
let secondsScrubbedRemaining = videoDuration - currentSeconds
|
|
|
|
leftLabelText = calculateTimeText(from: currentSeconds)
|
|
rightLabelText = calculateTimeText(from: secondsScrubbedRemaining)
|
|
scrubbingTimeLabelText = calculateTimeText(from: currentSeconds - beganScrubbingCurrentSeconds, isScrubbing: true)
|
|
}
|
|
|
|
private func calculateTimeText(from duration: Double, isScrubbing: Bool = false) -> String {
|
|
let isNegative = duration < 0
|
|
let duration = abs(duration)
|
|
let hours = floor(duration / 3600)
|
|
let minutes = duration.truncatingRemainder(dividingBy: 3600) / 60
|
|
let seconds = duration.truncatingRemainder(dividingBy: 3600).truncatingRemainder(dividingBy: 60)
|
|
|
|
let timeText: String
|
|
|
|
if hours != 0 {
|
|
timeText =
|
|
"\(Int(hours)):\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))"
|
|
} else {
|
|
timeText =
|
|
"\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))"
|
|
}
|
|
|
|
if isScrubbing {
|
|
return "\(isNegative ? "-" : "+") \(timeText)"
|
|
} else {
|
|
return "\(isNegative ? "-" : "") \(timeText)"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Injected Values
|
|
|
|
extension VideoPlayerViewModel {
|
|
// Injects custom values that override certain settings
|
|
func injectCustomValues(startFromBeginning: Bool = false) {
|
|
if startFromBeginning {
|
|
item.userData?.playbackPositionTicks = 0
|
|
item.userData?.playedPercentage = 0
|
|
sliderPercentage = 0
|
|
sliderPercentageChanged(newValue: 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Adjacent Items
|
|
|
|
extension VideoPlayerViewModel {
|
|
func getAdjacentEpisodes() {
|
|
guard let seriesID = item.seriesId, item.itemType == .episode else { return }
|
|
|
|
TvShowsAPI.getEpisodes(seriesId: seriesID,
|
|
userId: SessionManager.main.currentLogin.user.id,
|
|
adjacentTo: item.id,
|
|
limit: 3)
|
|
.sink(receiveCompletion: { completion in
|
|
self.handleAPIRequestError(completion: completion)
|
|
}, receiveValue: { response in
|
|
|
|
// 4 possible states:
|
|
// 1 - only current episode
|
|
// 2 - two episodes with next episode
|
|
// 3 - two episodes with previous episode
|
|
// 4 - three episodes with current in middle
|
|
|
|
// State 1
|
|
guard let items = response.items, items.count > 1 else { return }
|
|
|
|
if items.count == 2 {
|
|
if items[0].id == self.item.id {
|
|
// State 2
|
|
let nextItem = items[1]
|
|
|
|
nextItem.createVideoPlayerViewModel()
|
|
.sink { completion in
|
|
self.handleAPIRequestError(completion: completion)
|
|
} receiveValue: { viewModels in
|
|
for viewModel in viewModels {
|
|
viewModel.matchSubtitleStream(with: self)
|
|
viewModel.matchAudioStream(with: self)
|
|
}
|
|
|
|
self.nextItemVideoPlayerViewModel = viewModels.first
|
|
}
|
|
.store(in: &self.cancellables)
|
|
} else {
|
|
// State 3
|
|
let previousItem = items[0]
|
|
|
|
previousItem.createVideoPlayerViewModel()
|
|
.sink { completion in
|
|
self.handleAPIRequestError(completion: completion)
|
|
} receiveValue: { viewModels in
|
|
for viewModel in viewModels {
|
|
viewModel.matchSubtitleStream(with: self)
|
|
viewModel.matchAudioStream(with: self)
|
|
}
|
|
|
|
self.previousItemVideoPlayerViewModel = viewModels.first
|
|
}
|
|
.store(in: &self.cancellables)
|
|
}
|
|
} else {
|
|
// State 4
|
|
|
|
let previousItem = items[0]
|
|
let nextItem = items[2]
|
|
|
|
previousItem.createVideoPlayerViewModel()
|
|
.sink { completion in
|
|
self.handleAPIRequestError(completion: completion)
|
|
} receiveValue: { viewModels in
|
|
for viewModel in viewModels {
|
|
viewModel.matchSubtitleStream(with: self)
|
|
viewModel.matchAudioStream(with: self)
|
|
}
|
|
|
|
self.previousItemVideoPlayerViewModel = viewModels.first
|
|
}
|
|
.store(in: &self.cancellables)
|
|
|
|
nextItem.createVideoPlayerViewModel()
|
|
.sink { completion in
|
|
self.handleAPIRequestError(completion: completion)
|
|
} receiveValue: { viewModels in
|
|
for viewModel in viewModels {
|
|
viewModel.matchSubtitleStream(with: self)
|
|
viewModel.matchAudioStream(with: self)
|
|
}
|
|
|
|
self.nextItemVideoPlayerViewModel = viewModels.first
|
|
}
|
|
.store(in: &self.cancellables)
|
|
}
|
|
})
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
// Potential for experimental feature of syncing subtitle states among adjacent episodes
|
|
// when using previous & next item buttons and auto-play
|
|
|
|
private func matchSubtitleStream(with masterViewModel: VideoPlayerViewModel) {
|
|
if !masterViewModel.subtitlesEnabled {
|
|
matchSubtitlesEnabled(with: masterViewModel)
|
|
}
|
|
|
|
guard let masterSubtitleStream = masterViewModel.subtitleStreams
|
|
.first(where: { $0.index == masterViewModel.selectedSubtitleStreamIndex }),
|
|
let matchingSubtitleStream = subtitleStreams.first(where: { mediaStreamAboutEqual($0, masterSubtitleStream) }),
|
|
let matchingSubtitleStreamIndex = matchingSubtitleStream.index else { return }
|
|
|
|
selectedSubtitleStreamIndex = matchingSubtitleStreamIndex
|
|
}
|
|
|
|
private func matchAudioStream(with masterViewModel: VideoPlayerViewModel) {
|
|
guard let currentAudioStream = masterViewModel.audioStreams.first(where: { $0.index == masterViewModel.selectedAudioStreamIndex }),
|
|
let matchingAudioStream = audioStreams.first(where: { mediaStreamAboutEqual($0, currentAudioStream) }) else { return }
|
|
|
|
selectedAudioStreamIndex = matchingAudioStream.index ?? -1
|
|
}
|
|
|
|
private func matchSubtitlesEnabled(with masterViewModel: VideoPlayerViewModel) {
|
|
subtitlesEnabled = masterViewModel.subtitlesEnabled
|
|
}
|
|
|
|
private func mediaStreamAboutEqual(_ lhs: MediaStream, _ rhs: MediaStream) -> Bool {
|
|
lhs.displayTitle == rhs.displayTitle && lhs.language == rhs.language
|
|
}
|
|
}
|
|
|
|
// MARK: Progress Report Timer
|
|
|
|
extension VideoPlayerViewModel {
|
|
private func sendNewProgressReportWithTimer() {
|
|
progressReportTimer?.invalidate()
|
|
progressReportTimer = Timer.scheduledTimer(timeInterval: 0.7,
|
|
target: self,
|
|
selector: #selector(_sendProgressReport),
|
|
userInfo: nil,
|
|
repeats: false)
|
|
}
|
|
}
|
|
|
|
// MARK: Updates
|
|
|
|
extension VideoPlayerViewModel {
|
|
// MARK: sendPlayReport
|
|
|
|
func sendPlayReport() {
|
|
startTimeTicks = Int64(Date().timeIntervalSince1970) * 10_000_000
|
|
|
|
let subtitleStreamIndex = subtitlesEnabled ? selectedSubtitleStreamIndex : nil
|
|
|
|
let reportPlaybackStartRequest = ReportPlaybackStartRequest(canSeek: true,
|
|
itemId: item.id,
|
|
sessionId: response.playSessionId,
|
|
mediaSourceId: item.id,
|
|
audioStreamIndex: selectedAudioStreamIndex,
|
|
subtitleStreamIndex: subtitleStreamIndex,
|
|
isPaused: false,
|
|
isMuted: false,
|
|
positionTicks: item.userData?.playbackPositionTicks,
|
|
playbackStartTimeTicks: startTimeTicks,
|
|
volumeLevel: 100,
|
|
brightness: 100,
|
|
aspectRatio: nil,
|
|
playMethod: .directPlay,
|
|
liveStreamId: nil,
|
|
playSessionId: response.playSessionId,
|
|
repeatMode: .repeatNone,
|
|
nowPlayingQueue: nil,
|
|
playlistItemId: "playlistItem0")
|
|
|
|
PlaystateAPI.reportPlaybackStart(reportPlaybackStartRequest: reportPlaybackStartRequest)
|
|
.sink { completion in
|
|
self.handleAPIRequestError(completion: completion)
|
|
} receiveValue: { _ in
|
|
LogManager.log.debug("Start report sent for item: \(self.item.id ?? "No ID")")
|
|
}
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
// MARK: sendPauseReport
|
|
|
|
func sendPauseReport(paused: Bool) {
|
|
let subtitleStreamIndex = subtitlesEnabled ? selectedSubtitleStreamIndex : nil
|
|
|
|
let reportPlaybackStartRequest = ReportPlaybackStartRequest(canSeek: true,
|
|
itemId: item.id,
|
|
sessionId: response.playSessionId,
|
|
mediaSourceId: item.id,
|
|
audioStreamIndex: selectedAudioStreamIndex,
|
|
subtitleStreamIndex: subtitleStreamIndex,
|
|
isPaused: paused,
|
|
isMuted: false,
|
|
positionTicks: currentSecondTicks,
|
|
playbackStartTimeTicks: startTimeTicks,
|
|
volumeLevel: 100,
|
|
brightness: 100,
|
|
aspectRatio: nil,
|
|
playMethod: .directPlay,
|
|
liveStreamId: nil,
|
|
playSessionId: response.playSessionId,
|
|
repeatMode: .repeatNone,
|
|
nowPlayingQueue: nil,
|
|
playlistItemId: "playlistItem0")
|
|
|
|
PlaystateAPI.reportPlaybackStart(reportPlaybackStartRequest: reportPlaybackStartRequest)
|
|
.sink { completion in
|
|
self.handleAPIRequestError(completion: completion)
|
|
} receiveValue: { _ in
|
|
LogManager.log.debug("Pause report sent for item: \(self.item.id ?? "No ID")")
|
|
}
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
// MARK: sendProgressReport
|
|
|
|
func sendProgressReport() {
|
|
let subtitleStreamIndex = subtitlesEnabled ? selectedSubtitleStreamIndex : nil
|
|
|
|
let progressInfo = ReportPlaybackProgressRequest(canSeek: true,
|
|
itemId: item.id,
|
|
sessionId: response.playSessionId,
|
|
mediaSourceId: item.id,
|
|
audioStreamIndex: selectedAudioStreamIndex,
|
|
subtitleStreamIndex: subtitleStreamIndex,
|
|
isPaused: false,
|
|
isMuted: false,
|
|
positionTicks: currentSecondTicks,
|
|
playbackStartTimeTicks: startTimeTicks,
|
|
volumeLevel: nil,
|
|
brightness: nil,
|
|
aspectRatio: nil,
|
|
playMethod: .directPlay,
|
|
liveStreamId: nil,
|
|
playSessionId: response.playSessionId,
|
|
repeatMode: .repeatNone,
|
|
nowPlayingQueue: nil,
|
|
playlistItemId: "playlistItem0")
|
|
|
|
lastProgressReport = progressInfo
|
|
|
|
sendNewProgressReportWithTimer()
|
|
}
|
|
|
|
@objc
|
|
private func _sendProgressReport() {
|
|
guard let lastProgressReport = lastProgressReport else { return }
|
|
|
|
PlaystateAPI.reportPlaybackProgress(reportPlaybackProgressRequest: lastProgressReport)
|
|
.sink { completion in
|
|
self.handleAPIRequestError(completion: completion)
|
|
} receiveValue: { _ in
|
|
LogManager.log.debug("Playback progress sent for item: \(self.item.id ?? "No ID")")
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
self.lastProgressReport = nil
|
|
}
|
|
|
|
// MARK: sendStopReport
|
|
|
|
func sendStopReport() {
|
|
let reportPlaybackStoppedRequest = ReportPlaybackStoppedRequest(itemId: item.id,
|
|
sessionId: response.playSessionId,
|
|
mediaSourceId: item.id,
|
|
positionTicks: currentSecondTicks,
|
|
liveStreamId: nil,
|
|
playSessionId: response.playSessionId,
|
|
failed: nil,
|
|
nextMediaType: nil,
|
|
playlistItemId: "playlistItem0",
|
|
nowPlayingQueue: nil)
|
|
|
|
PlaystateAPI.reportPlaybackStopped(reportPlaybackStoppedRequest: reportPlaybackStoppedRequest)
|
|
.sink { completion in
|
|
self.handleAPIRequestError(completion: completion)
|
|
} receiveValue: { _ in
|
|
LogManager.log.debug("Stop report sent for item: \(self.item.id ?? "No ID")")
|
|
Notifications[.didSendStopReport].post(object: self.item.id)
|
|
}
|
|
.store(in: &cancellables)
|
|
}
|
|
}
|
|
|
|
// MARK: Embedded/Normal Subtitle Streams
|
|
|
|
extension VideoPlayerViewModel {
|
|
func createEmbeddedSubtitleStream(with subtitleStream: MediaStream) -> URL {
|
|
guard let baseURL = URLComponents(url: directStreamURL, resolvingAgainstBaseURL: false) else { fatalError() }
|
|
guard let queryItems = baseURL.queryItems else { fatalError() }
|
|
|
|
var newURL = baseURL
|
|
var newQueryItems = queryItems
|
|
|
|
newQueryItems.removeAll(where: { $0.name == "SubtitleStreamIndex" })
|
|
newQueryItems.removeAll(where: { $0.name == "SubtitleMethod" })
|
|
|
|
newURL.addQueryItem(name: "SubtitleMethod", value: "Encode")
|
|
newURL.addQueryItem(name: "SubtitleStreamIndex", value: "\(subtitleStream.index ?? -1)")
|
|
|
|
return newURL.url!
|
|
}
|
|
}
|
|
|
|
// MARK: Equatable
|
|
|
|
extension VideoPlayerViewModel: Equatable {
|
|
static func == (lhs: VideoPlayerViewModel, rhs: VideoPlayerViewModel) -> Bool {
|
|
lhs.item.id == rhs.item.id &&
|
|
lhs.item.userData?.playbackPositionTicks == rhs.item.userData?.playbackPositionTicks
|
|
}
|
|
}
|
|
|
|
// MARK: Hashable
|
|
|
|
extension VideoPlayerViewModel: Hashable {
|
|
func hash(into hasher: inout Hasher) {
|
|
hasher.combine(item)
|
|
hasher.combine(directStreamURL)
|
|
hasher.combine(filename)
|
|
hasher.combine(versionName)
|
|
}
|
|
}
|