mirror of
https://github.com/yattee/yattee.git
synced 2024-12-22 13:33:42 +00:00
Conditional proxying
I added a new feature. When instances are not proxied, Yattee first checks the URL to make sure it is not a restricted video. Usually, music videos and sports content can only be played back by the same IP address that requested the URL in the first place. That is why some videos do not play when the proxy is disabled. This approach has multiple advantages. First and foremost, It reduced the load on Invidious/Piped instances, since users can now directly access the videos without going through the instance, which might be severely bandwidth limited. Secondly, users don't need to manually turn on the proxy when they want to watch IP address bound content, since Yattee automatically proxies such content. Furthermore, adding the proxy option allows mitigating some severe playback issues with invidious instances. Invidious by default returns proxied URLs for videos, and due to some bug in the Invidious proxy, scrubbing or continuing playback at a random timestamp can lead to severe wait times for the users. This should fix numerous playback issues: #666, #626, #590, #585, #498, #457, #400
This commit is contained in:
parent
1fe8a32fb8
commit
6eba2a45c8
@ -655,7 +655,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
kind: .adaptive,
|
||||
encoding: videoStream["encoding"].string,
|
||||
videoFormat: videoStream["type"].string,
|
||||
bitrate: videoStream["bitrate"].int
|
||||
bitrate: videoStream["bitrate"].int,
|
||||
requestRange: videoStream["init"].string ?? videoStream["index"].string
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -491,6 +491,35 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
)
|
||||
}
|
||||
|
||||
static func nonProxiedAsset(asset: AVURLAsset, completion: @escaping (AVURLAsset?) -> Void) {
|
||||
guard var urlComponents = URLComponents(url: asset.url, resolvingAgainstBaseURL: false) else {
|
||||
completion(asset)
|
||||
return
|
||||
}
|
||||
|
||||
guard let hostItem = urlComponents.queryItems?.first(where: { $0.name == "host" }),
|
||||
let hostValue = hostItem.value
|
||||
else {
|
||||
completion(asset)
|
||||
return
|
||||
}
|
||||
|
||||
urlComponents.host = hostValue
|
||||
|
||||
guard let newUrl = urlComponents.url else {
|
||||
completion(asset)
|
||||
return
|
||||
}
|
||||
|
||||
completion(AVURLAsset(url: newUrl))
|
||||
}
|
||||
|
||||
// Overload used for hlsURLS
|
||||
static func nonProxiedAsset(url: URL, completion: @escaping (AVURLAsset?) -> Void) {
|
||||
let asset = AVURLAsset(url: url)
|
||||
nonProxiedAsset(asset: asset, completion: completion)
|
||||
}
|
||||
|
||||
private func extractVideo(from content: JSON) -> Video? {
|
||||
let details = content.dictionaryValue
|
||||
|
||||
@ -579,10 +608,11 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
return nil
|
||||
}
|
||||
|
||||
return URL(string: thumbnailURL
|
||||
.absoluteString
|
||||
.replacingOccurrences(of: "hqdefault", with: quality.filename)
|
||||
.replacingOccurrences(of: "maxresdefault", with: quality.filename)
|
||||
return URL(
|
||||
string: thumbnailURL
|
||||
.absoluteString
|
||||
.replacingOccurrences(of: "hqdefault", with: quality.filename)
|
||||
.replacingOccurrences(of: "maxresdefault", with: quality.filename)
|
||||
)
|
||||
}
|
||||
|
||||
@ -688,6 +718,19 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
let resolution = Stream.Resolution.from(resolution: quality, fps: fps)
|
||||
let videoFormat = videoStream.dictionaryValue["format"]?.string
|
||||
let bitrate = videoStream.dictionaryValue["bitrate"]?.int
|
||||
var requestRange: String?
|
||||
|
||||
if let initStart = videoStream.dictionaryValue["initStart"]?.int,
|
||||
let initEnd = videoStream.dictionaryValue["initEnd"]?.int
|
||||
{
|
||||
requestRange = "\(initStart)-\(initEnd)"
|
||||
} else if let indexStart = videoStream.dictionaryValue["indexStart"]?.int,
|
||||
let indexEnd = videoStream.dictionaryValue["indexEnd"]?.int
|
||||
{
|
||||
requestRange = "\(indexStart)-\(indexEnd)"
|
||||
} else {
|
||||
requestRange = nil
|
||||
}
|
||||
|
||||
if videoOnly {
|
||||
streams.append(
|
||||
@ -698,7 +741,8 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
resolution: resolution,
|
||||
kind: .adaptive,
|
||||
videoFormat: videoFormat,
|
||||
bitrate: bitrate
|
||||
bitrate: bitrate,
|
||||
requestRange: requestRange
|
||||
)
|
||||
)
|
||||
} else {
|
||||
|
@ -95,7 +95,7 @@ enum VideosApp: String, CaseIterable {
|
||||
}
|
||||
|
||||
var allowsDisablingVidoesProxying: Bool {
|
||||
self == .invidious
|
||||
self == .invidious || self == .piped
|
||||
}
|
||||
|
||||
var supportsOpeningVideosByID: Bool {
|
||||
|
@ -26,7 +26,7 @@ final class NetworkStateModel: ObservableObject {
|
||||
}
|
||||
|
||||
var bufferingStateText: String? {
|
||||
guard detailsAvailable else { return nil }
|
||||
guard detailsAvailable && player.hasStarted else { return nil }
|
||||
return String(format: "%.0f%%", bufferingState)
|
||||
}
|
||||
|
||||
|
@ -40,6 +40,11 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
|
||||
var isLoadingVideo = false
|
||||
|
||||
var hasStarted = false
|
||||
var isPaused: Bool {
|
||||
avPlayer.timeControlStatus == .paused
|
||||
}
|
||||
|
||||
var isPlaying: Bool {
|
||||
avPlayer.timeControlStatus == .playing
|
||||
}
|
||||
@ -158,6 +163,12 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
}
|
||||
|
||||
avPlayer.play()
|
||||
|
||||
// Setting hasStarted to true the first time player started
|
||||
if !hasStarted {
|
||||
hasStarted = true
|
||||
}
|
||||
|
||||
model.objectWillChange.send()
|
||||
}
|
||||
|
||||
@ -180,6 +191,7 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
|
||||
func stop() {
|
||||
avPlayer.replaceCurrentItem(with: nil)
|
||||
hasStarted = false
|
||||
}
|
||||
|
||||
func cancelLoads() {
|
||||
|
@ -44,6 +44,8 @@ final class MPVBackend: PlayerBackend {
|
||||
}
|
||||
}}
|
||||
|
||||
var hasStarted = false
|
||||
var isPaused = false
|
||||
var isPlaying = true { didSet {
|
||||
networkStateTimer.start()
|
||||
|
||||
@ -337,7 +339,6 @@ final class MPVBackend: PlayerBackend {
|
||||
}
|
||||
|
||||
func play() {
|
||||
isPlaying = true
|
||||
startClientUpdates()
|
||||
|
||||
if controls.presentingControls {
|
||||
@ -354,13 +355,22 @@ final class MPVBackend: PlayerBackend {
|
||||
}
|
||||
|
||||
client?.play()
|
||||
|
||||
isPlaying = true
|
||||
isPaused = false
|
||||
|
||||
// Setting hasStarted to true the first time player started
|
||||
if !hasStarted {
|
||||
hasStarted = true
|
||||
}
|
||||
}
|
||||
|
||||
func pause() {
|
||||
isPlaying = false
|
||||
stopClientUpdates()
|
||||
|
||||
client?.pause()
|
||||
isPaused = true
|
||||
isPlaying = false
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
@ -377,6 +387,9 @@ final class MPVBackend: PlayerBackend {
|
||||
|
||||
func stop() {
|
||||
client?.stop()
|
||||
isPlaying = false
|
||||
isPaused = false
|
||||
hasStarted = false
|
||||
}
|
||||
|
||||
func seek(to time: CMTime, seekType _: SeekType, completionHandler: ((Bool) -> Void)?) {
|
||||
@ -392,8 +405,8 @@ final class MPVBackend: PlayerBackend {
|
||||
}
|
||||
|
||||
func closeItem() {
|
||||
client?.pause()
|
||||
client?.stop()
|
||||
pause()
|
||||
stop()
|
||||
self.video = nil
|
||||
self.stream = nil
|
||||
}
|
||||
|
@ -19,6 +19,8 @@ protocol PlayerBackend {
|
||||
var loadedVideo: Bool { get }
|
||||
var isLoadingVideo: Bool { get }
|
||||
|
||||
var hasStarted: Bool { get }
|
||||
var isPaused: Bool { get }
|
||||
var isPlaying: Bool { get }
|
||||
var isSeeking: Bool { get }
|
||||
var playerItemDuration: CMTime? { get }
|
||||
|
@ -298,6 +298,14 @@ final class PlayerModel: ObservableObject {
|
||||
backend.isPlaying
|
||||
}
|
||||
|
||||
var isPaused: Bool {
|
||||
backend.isPaused
|
||||
}
|
||||
|
||||
var hasStarted: Bool {
|
||||
backend.hasStarted
|
||||
}
|
||||
|
||||
var playerItemDuration: CMTime? {
|
||||
guard !currentItem.isNil else {
|
||||
return nil
|
||||
|
@ -74,7 +74,7 @@ extension PlayerModel {
|
||||
preservedTime = currentItem.playbackTime
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let self = self else { return }
|
||||
guard let video = item.video else {
|
||||
return
|
||||
}
|
||||
@ -94,7 +94,9 @@ extension PlayerModel {
|
||||
}
|
||||
} else {
|
||||
self.videoBeingOpened = nil
|
||||
self.availableStreams = self.streamsWithInstance(instance: playerInstance, streams: video.streams)
|
||||
self.streamsWithInstance(instance: playerInstance, streams: video.streams) { processedStreams in
|
||||
self.availableStreams = processedStreams
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Siesta
|
||||
import SwiftUI
|
||||
@ -41,7 +42,9 @@ extension PlayerModel {
|
||||
self.logger.info("ignoring loaded streams from \(instance.description) as current video has changed")
|
||||
return
|
||||
}
|
||||
self.availableStreams = self.streamsWithInstance(instance: instance, streams: video.streams)
|
||||
self.streamsWithInstance(instance: instance, streams: video.streams) { processedStreams in
|
||||
self.availableStreams = processedStreams
|
||||
}
|
||||
} else {
|
||||
self.logger.critical("no streams available from \(instance.description)")
|
||||
}
|
||||
@ -53,20 +56,127 @@ extension PlayerModel {
|
||||
}
|
||||
}
|
||||
|
||||
func streamsWithInstance(instance: Instance, streams: [Stream]) -> [Stream] {
|
||||
streams.map { stream in
|
||||
stream.instance = instance
|
||||
func streamsWithInstance(instance _: Instance, streams: [Stream], completion: @escaping ([Stream]) -> Void) {
|
||||
let forbiddenAssetTestGroup = DispatchGroup()
|
||||
var hasForbiddenAsset = false
|
||||
|
||||
if instance.app == .invidious, instance.proxiesVideos {
|
||||
if let audio = stream.audioAsset {
|
||||
stream.audioAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: audio)
|
||||
}
|
||||
if let video = stream.videoAsset {
|
||||
stream.videoAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: video)
|
||||
let (nonHLSAssets, hlsURLs) = getAssets(from: streams)
|
||||
|
||||
if let randomStream = nonHLSAssets.randomElement() {
|
||||
let instance = randomStream.0
|
||||
let asset = randomStream.1
|
||||
let url = randomStream.2
|
||||
let requestRange = randomStream.3
|
||||
|
||||
if let asset = asset, let instance = instance, !instance.proxiesVideos {
|
||||
if instance.app == .invidious {
|
||||
testAsset(url: url, range: requestRange, isHLS: false, forbiddenAssetTestGroup: forbiddenAssetTestGroup) { isForbidden in
|
||||
hasForbiddenAsset = isForbidden
|
||||
}
|
||||
} else if instance.app == .piped {
|
||||
testPipedAssets(asset: asset, requestRange: requestRange!, isHLS: false, forbiddenAssetTestGroup: forbiddenAssetTestGroup, completion: { isForbidden in
|
||||
hasForbiddenAsset = isForbidden
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if let randomHLS = hlsURLs.randomElement() {
|
||||
let instance = randomHLS.0
|
||||
let asset = AVURLAsset(url: randomHLS.1)
|
||||
|
||||
return stream
|
||||
if instance?.app == .piped {
|
||||
testPipedAssets(asset: asset, requestRange: nil, isHLS: false, forbiddenAssetTestGroup: forbiddenAssetTestGroup, completion: { isForbidden in
|
||||
hasForbiddenAsset = isForbidden
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
forbiddenAssetTestGroup.notify(queue: .main) {
|
||||
let processedStreams = streams.map { stream -> Stream in
|
||||
if let instance = stream.instance {
|
||||
if instance.app == .invidious {
|
||||
if hasForbiddenAsset || instance.proxiesVideos {
|
||||
if let audio = stream.audioAsset {
|
||||
stream.audioAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: audio)
|
||||
}
|
||||
if let video = stream.videoAsset {
|
||||
stream.videoAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: video)
|
||||
}
|
||||
}
|
||||
} else if instance.app == .piped, !instance.proxiesVideos, !hasForbiddenAsset {
|
||||
if let hlsURL = stream.hlsURL {
|
||||
forbiddenAssetTestGroup.enter()
|
||||
PipedAPI.nonProxiedAsset(url: hlsURL) { nonProxiedURL in
|
||||
if let nonProxiedURL = nonProxiedURL {
|
||||
stream.hlsURL = nonProxiedURL.url
|
||||
}
|
||||
forbiddenAssetTestGroup.leave()
|
||||
}
|
||||
} else {
|
||||
if let audio = stream.audioAsset {
|
||||
forbiddenAssetTestGroup.enter()
|
||||
PipedAPI.nonProxiedAsset(asset: audio) { nonProxiedAudioAsset in
|
||||
stream.audioAsset = nonProxiedAudioAsset
|
||||
forbiddenAssetTestGroup.leave()
|
||||
}
|
||||
}
|
||||
if let video = stream.videoAsset {
|
||||
forbiddenAssetTestGroup.enter()
|
||||
PipedAPI.nonProxiedAsset(asset: video) { nonProxiedVideoAsset in
|
||||
stream.videoAsset = nonProxiedVideoAsset
|
||||
forbiddenAssetTestGroup.leave()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return stream
|
||||
}
|
||||
|
||||
forbiddenAssetTestGroup.notify(queue: .main) {
|
||||
completion(processedStreams)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getAssets(from streams: [Stream]) -> (nonHLSAssets: [(Instance?, AVURLAsset?, URL, String?)], hlsURLs: [(Instance?, URL)]) {
|
||||
var nonHLSAssets = [(Instance?, AVURLAsset?, URL, String?)]()
|
||||
var hlsURLs = [(Instance?, URL)]()
|
||||
|
||||
for stream in streams {
|
||||
if stream.isHLS {
|
||||
if let url = stream.hlsURL?.url {
|
||||
hlsURLs.append((stream.instance, url))
|
||||
}
|
||||
} else {
|
||||
if let asset = stream.audioAsset {
|
||||
nonHLSAssets.append((stream.instance, asset, asset.url, stream.requestRange))
|
||||
}
|
||||
if let asset = stream.videoAsset {
|
||||
nonHLSAssets.append((stream.instance, asset, asset.url, stream.requestRange))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (nonHLSAssets, hlsURLs)
|
||||
}
|
||||
|
||||
private func testAsset(url: URL, range: String?, isHLS: Bool, forbiddenAssetTestGroup: DispatchGroup, completion: @escaping (Bool) -> Void) {
|
||||
let randomEnd = Int.random(in: 200 ... 800)
|
||||
let requestRange = range ?? "0-\(randomEnd)"
|
||||
let HTTPStatusForbidden = 403
|
||||
|
||||
forbiddenAssetTestGroup.enter()
|
||||
URLTester.testURLResponse(url: url, range: requestRange, isHLS: isHLS) { statusCode in
|
||||
completion(statusCode == HTTPStatusForbidden)
|
||||
forbiddenAssetTestGroup.leave()
|
||||
}
|
||||
}
|
||||
|
||||
private func testPipedAssets(asset: AVURLAsset, requestRange: String?, isHLS: Bool, forbiddenAssetTestGroup: DispatchGroup, completion: @escaping (Bool) -> Void) {
|
||||
PipedAPI.nonProxiedAsset(asset: asset) { nonProxiedAsset in
|
||||
if let nonProxiedAsset = nonProxiedAsset {
|
||||
self.testAsset(url: nonProxiedAsset.url, range: requestRange, isHLS: isHLS, forbiddenAssetTestGroup: forbiddenAssetTestGroup, completion: completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -170,6 +170,7 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
var encoding: String?
|
||||
var videoFormat: String?
|
||||
var bitrate: Int?
|
||||
var requestRange: String?
|
||||
|
||||
init(
|
||||
instance: Instance? = nil,
|
||||
@ -181,7 +182,8 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
kind: Kind = .hls,
|
||||
encoding: String? = nil,
|
||||
videoFormat: String? = nil,
|
||||
bitrate: Int? = nil
|
||||
bitrate: Int? = nil,
|
||||
requestRange: String? = nil
|
||||
) {
|
||||
self.instance = instance
|
||||
self.audioAsset = audioAsset
|
||||
@ -193,6 +195,7 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
self.encoding = encoding
|
||||
format = .from(videoFormat ?? "")
|
||||
self.bitrate = bitrate
|
||||
self.requestRange = requestRange
|
||||
}
|
||||
|
||||
var isLocal: Bool {
|
||||
|
@ -4,7 +4,7 @@
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.78Z5H3M6RJ.stream.yattee.app.urlbookmarks</string>
|
||||
<string>group.stonerl.yattee.app.url</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
@ -10,26 +10,28 @@ struct OpeningStream: View {
|
||||
}
|
||||
|
||||
var visible: Bool {
|
||||
(!player.currentItem.isNil && !player.videoBeingOpened.isNil) || (player.isLoadingVideo && !model.pausedForCache && !player.isSeeking)
|
||||
(!player.currentItem.isNil && !player.videoBeingOpened.isNil) ||
|
||||
(player.isLoadingVideo && !model.pausedForCache && !player.isSeeking) ||
|
||||
!player.hasStarted
|
||||
}
|
||||
|
||||
var reason: String {
|
||||
guard player.videoBeingOpened == nil else {
|
||||
return "Loading streams...".localized()
|
||||
return "Loading streams…".localized()
|
||||
}
|
||||
|
||||
if player.musicMode {
|
||||
return "Opening audio stream...".localized()
|
||||
return "Opening audio stream…".localized()
|
||||
}
|
||||
|
||||
if let selection = player.streamSelection {
|
||||
if selection.isLocal {
|
||||
return "Opening file...".localized()
|
||||
return "Opening file…".localized()
|
||||
}
|
||||
return String(format: "Opening %@ stream...".localized(), selection.shortQuality)
|
||||
return String(format: "Opening %@ stream…".localized(), selection.shortQuality)
|
||||
}
|
||||
|
||||
return "Loading streams...".localized()
|
||||
return "Loading streams…".localized()
|
||||
}
|
||||
|
||||
var state: String? {
|
||||
|
@ -75,16 +75,20 @@ struct PlayerControls: View {
|
||||
}
|
||||
|
||||
VStack {
|
||||
Spacer()
|
||||
ZStack {
|
||||
VStack(spacing: 0) {
|
||||
ZStack {
|
||||
OpeningStream()
|
||||
NetworkState()
|
||||
GeometryReader { geometry in
|
||||
VStack(spacing: 0) {
|
||||
ZStack {
|
||||
OpeningStream()
|
||||
NetworkState()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
.position(
|
||||
x: geometry.size.width / 2,
|
||||
y: geometry.size.height / 2
|
||||
)
|
||||
}
|
||||
.offset(y: playerControlsLayout.osdVerticalOffset + 5)
|
||||
|
||||
if showControls {
|
||||
Section {
|
||||
|
@ -278,10 +278,6 @@ enum PlayerControlsLayout: String, CaseIterable, Defaults.Serializable {
|
||||
}
|
||||
}
|
||||
|
||||
var osdVerticalOffset: Double {
|
||||
buttonSize
|
||||
}
|
||||
|
||||
var osdProgressBarHeight: Double {
|
||||
switch self {
|
||||
case .tvRegular:
|
||||
|
103
Shared/URLTester.swift
Normal file
103
Shared/URLTester.swift
Normal file
@ -0,0 +1,103 @@
|
||||
import Foundation
|
||||
import Logging
|
||||
|
||||
enum URLTester {
|
||||
private static let hlsMediaPrefix = "#EXT-X-MEDIA:"
|
||||
private static let hlsInfPrefix = "#EXTINF:"
|
||||
private static let uriRegex = "(?<=URI=\")(.*?)(?=\")"
|
||||
private static let HTTPStatusForbidden = 403
|
||||
|
||||
static func testURLResponse(url: URL, range: String, isHLS: Bool, completion: @escaping (Int) -> Void) {
|
||||
if isHLS {
|
||||
parseAndTestHLSManifest(manifestUrl: url, range: range, completion: completion)
|
||||
} else {
|
||||
httpRequest(url: url, range: range) { statusCode, _ in
|
||||
completion(statusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func httpRequest(url: URL, range: String, completion: @escaping (Int, URLSessionDataTask?) -> Void) {
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "HEAD"
|
||||
request.setValue("bytes=\(range)", forHTTPHeaderField: "Range")
|
||||
|
||||
var dataTask: URLSessionDataTask?
|
||||
dataTask = URLSession.shared.dataTask(with: request) { _, response, _ in
|
||||
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? HTTPStatusForbidden
|
||||
Logger(label: "stream.yattee.httpRequest").info("URL: \(url) | Status Code: \(statusCode)")
|
||||
completion(statusCode, dataTask)
|
||||
}
|
||||
dataTask?.resume()
|
||||
}
|
||||
|
||||
static func parseAndTestHLSManifest(manifestUrl: URL, range: String, completion: @escaping (Int) -> Void) {
|
||||
recursivelyParseManifest(manifestUrl: manifestUrl) { allURLs in
|
||||
if let url = allURLs.randomElement() {
|
||||
httpRequest(url: url, range: range) { statusCode, _ in
|
||||
completion(statusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func recursivelyParseManifest(manifestUrl: URL, fullyParsed: @escaping ([URL]) -> Void) {
|
||||
parseHLSManifest(manifestUrl: manifestUrl) { urls in
|
||||
var allURLs = [URL]()
|
||||
let group = DispatchGroup()
|
||||
for url in urls {
|
||||
if url.pathExtension == "m3u8" {
|
||||
group.enter()
|
||||
recursivelyParseManifest(manifestUrl: url) { subUrls in
|
||||
allURLs += subUrls
|
||||
group.leave()
|
||||
}
|
||||
} else {
|
||||
allURLs.append(url)
|
||||
}
|
||||
}
|
||||
group.notify(queue: .main) {
|
||||
fullyParsed(allURLs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseHLSManifest(manifestUrl: URL, completion: @escaping ([URL]) -> Void) {
|
||||
URLSession.shared.dataTask(with: manifestUrl) { data, _, _ in
|
||||
guard let data = data,
|
||||
let manifest = String(data: data, encoding: .utf8),
|
||||
!manifest.isEmpty
|
||||
else {
|
||||
Logger(label: "stream.yattee.httpRequest").error("Cannot read or empty HLS manifest")
|
||||
completion([])
|
||||
return
|
||||
}
|
||||
|
||||
let lines = manifest.split(separator: "\n")
|
||||
var mediaURLs: [URL] = []
|
||||
|
||||
for index in 0 ..< lines.count {
|
||||
let lineString = String(lines[index])
|
||||
|
||||
if lineString.hasPrefix(hlsMediaPrefix),
|
||||
let uriRange = lineString.range(of: uriRegex, options: .regularExpression)
|
||||
{
|
||||
let uri = lineString[uriRange]
|
||||
if let url = URL(string: String(uri)) {
|
||||
mediaURLs.append(url)
|
||||
}
|
||||
} else if lineString.hasPrefix(hlsInfPrefix), index < lines.count - 1 {
|
||||
let possibleURL = String(lines[index + 1])
|
||||
let baseURL = manifestUrl.deletingLastPathComponent()
|
||||
if let relativeURL = URL(string: possibleURL, relativeTo: baseURL),
|
||||
relativeURL.scheme != nil
|
||||
{
|
||||
mediaURLs.append(relativeURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
completion(mediaURLs)
|
||||
}
|
||||
.resume()
|
||||
}
|
||||
}
|
@ -129,7 +129,7 @@
|
||||
"LIVE" = "مباشر";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Loading streams..." = "تحميل بثوث...";
|
||||
"Loading streams…" = "تحميل بثوث…";
|
||||
"Loading..." = "تحميل...";
|
||||
|
||||
/* Video duration filter in search */
|
||||
@ -163,8 +163,8 @@
|
||||
"Open Settings" = "فتح الإعدادات";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Opening %@ stream..." = "فتح بث %@ ...";
|
||||
"Opening audio stream..." = "فتح بث صوتي...";
|
||||
"Opening %@ stream…" = "فتح بث %@ …";
|
||||
"Opening audio stream…" = "فتح بث صوتي…";
|
||||
"Orientation" = "اتجاه";
|
||||
"Play in PiP" = "تشغيل في الفيديو المصغر";
|
||||
"Play Last" = "تشغيل الأخير";
|
||||
@ -558,7 +558,7 @@
|
||||
"Are you sure you want to clear cache?" = "هل أنت متأكد من أنك تريد مسح ذاكرة التخزين المؤقت؟";
|
||||
"Gesture settings control skipping interval for remote arrow buttons (for 2nd generation Siri Remote or newer). Changing system controls settings requires restart." = "التحكم في إعدادات إيماءة الفاصل الزمني للتخطي لأزرار الأسهم عن بعد (للجيل الثاني من Siri Remote أو أحدث). تغيير إعدادات نظام عناصر التحكم يتطلب إعادة بدء التشغيل.";
|
||||
"Opened File" = "ملف مفتوح";
|
||||
"Opening file..." = "فتح الملف...";
|
||||
"Opening file…" = "فتح الملف…";
|
||||
"Short videos: hidden" = "مقاطع الفيديو القصيرة: مخفية";
|
||||
"Mark channel feed as watched" = "وضع علامة تمت المشاهدة على محتوى القناة";
|
||||
"Gesture settings control skipping interval for double click on left/right side of the player. Changing system controls settings requires restart." = "التحكم في إعدادات إيماءة فترة التخطي للنقر المزدوج على الجانب الأيسر/الأيمن من المشغل. تغيير إعدادات نظام عناصر التحكم يتطلب إعادة بدء التشغيل.";
|
||||
|
@ -277,11 +277,11 @@
|
||||
"Large" = "Böyük";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Loading streams..." = "Yayımlar yüklənilir...";
|
||||
"Loading streams…" = "Yayımlar yüklənilir…";
|
||||
"Only when signed in" = "Yalnız daxil olduqda";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Opening %@ stream..." = "%@ yayımı açılır...";
|
||||
"Opening %@ stream…" = "%@ yayımı açılır…";
|
||||
"Matrix Chat" = "Matrix Söhbət";
|
||||
|
||||
/* Player controls layout size */
|
||||
@ -296,7 +296,7 @@
|
||||
"Open Settings" = "Tənzimləmələri Aç";
|
||||
"Movies" = "Filmlər";
|
||||
"No description" = "Açıqlama yoxdur";
|
||||
"Opening audio stream..." = "Səs yayımı açılır...";
|
||||
"Opening audio stream…" = "Səs yayımı açılır…";
|
||||
"Password" = "Şifrə";
|
||||
"Preferred Formats" = "Üstünlük Verilən Formatlar";
|
||||
"Quality Profile" = "Profil Keyfiyyəti";
|
||||
|
@ -113,7 +113,7 @@
|
||||
"LIVE" = "EN VIU";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Loading streams..." = "S'estan carregant els fluxos...";
|
||||
"Loading streams…" = "S'estan carregant els fluxos…";
|
||||
"Loading..." = "Carregant...";
|
||||
"Locations" = "Ubicacions";
|
||||
"Lock portrait mode" = "Bloqueja el mode vertical";
|
||||
@ -147,7 +147,7 @@
|
||||
"Offtopic in Music Videos" = "Offtopic als vídeos musicals";
|
||||
"Open \"Playlists\" tab to create new one" = "Obriu la pestanya \"Llistes de reproducció\" per crear-ne una de nova";
|
||||
"Open Settings" = "Obriu Configuració";
|
||||
"Opening audio stream..." = "Obrint la reproducció d'àudio...";
|
||||
"Opening audio stream…" = "Obrint la reproducció d'àudio…";
|
||||
"Orientation" = "Orientació";
|
||||
|
||||
/* SponsorBlock category name */
|
||||
@ -454,7 +454,7 @@
|
||||
"Low" = "Baix";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Opening %@ stream..." = "S'està obrint %@...";
|
||||
"Opening %@ stream…" = "S'està obrint %@…";
|
||||
"Only when signed in" = "Només quan s'ha iniciat la sessió";
|
||||
"Password" = "Contrasenya";
|
||||
"Part of a video promoting a product or service not directly related to the creator. The creator will receive payment or compensation in the form of money or free products." = "Part d'un vídeo que promociona un producte o servei no relacionat directament amb el creador. El creador rebrà un pagament o compensació en forma de diners o productes gratuïts.";
|
||||
|
@ -166,7 +166,7 @@
|
||||
"Only when signed in" = "Pouze když přihlášený";
|
||||
"Open \"Playlists\" tab to create new one" = "Otevřete kartu \"Playlisty\", aby jste vytvořili nový";
|
||||
"Open Settings" = "Otevřete Nastavení";
|
||||
"Opening audio stream..." = "Otevírám audio stream...";
|
||||
"Opening audio stream…" = "Otevírám audio stream…";
|
||||
"Orientation" = "Orientace";
|
||||
"Password" = "Heslo";
|
||||
"Pause" = "Pauza";
|
||||
@ -401,11 +401,11 @@
|
||||
"Promoting a product or service that is directly related to the creator themselves. This usually includes merchandise or promotion of monetized platforms." = "Propagace produktu nebo služby, která přímo souvisí s tvůrcem samotným. Obvykle se jedná o zboží nebo zpeněžené platformy.";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Loading streams..." = "Načítaní streamu...";
|
||||
"Loading streams…" = "Načítaní streamu…";
|
||||
"Matrix Chat" = "Matrix Chat";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Opening %@ stream..." = "Otevírám %@ stream...";
|
||||
"Opening %@ stream…" = "Otevírám %@ stream…";
|
||||
|
||||
/* SponsorBlock category name */
|
||||
"Outro" = "Zakončení";
|
||||
@ -543,7 +543,7 @@
|
||||
"Available" = "Dostupné";
|
||||
"Are you sure you want to remove %@ from Favorites?" = "Opravdu chcete odstranit %@ z oblíbených položek?";
|
||||
"Use system controls with AVPlayer" = "Použití systémových ovládacích prvků s AVPlayerem";
|
||||
"Opening file..." = "Otvírání souboru...";
|
||||
"Opening file…" = "Otvírání souboru…";
|
||||
"No videos to show" = "Žádná videa k zobrazení";
|
||||
"Autoplay next" = "Automaticky přehrát další";
|
||||
"Inspector" = "Inspektor";
|
||||
|
@ -182,7 +182,7 @@
|
||||
"Large" = "Groß";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Loading streams..." = "Lädt Streams …";
|
||||
"Loading streams…" = "Lädt Streams …";
|
||||
|
||||
/* Video duration filter in search */
|
||||
"Long" = "Lang";
|
||||
@ -210,7 +210,7 @@
|
||||
"Only when signed in" = "Nur wenn Sie eingeloggt sind";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Opening %@ stream..." = "Öffne %@-stream …";
|
||||
"Opening %@ stream…" = "Öffne %@-Stream …";
|
||||
"Connection failed" = "Verbindung fehlgeschlagen";
|
||||
"Continue from %@" = "Ab %@ fortsetzen";
|
||||
"Contributing" = "Beitragen";
|
||||
@ -227,7 +227,7 @@
|
||||
"I want to ask a question" = "Ich möchte eine Frage stellen";
|
||||
"If you are interested what's coming in future updates, you can track project Milestones." = "Wenn Sie sich für künftige Updates interessieren, können Sie die Meilensteine des Projekts verfolgen.";
|
||||
"Large layout is not suitable for all devices and using it may cause controls not to fit on the screen." = "Das große Layout ist nicht für alle Geräte geeignet und kann dazu führen, dass die Bedienelemente nicht auf den Bildschirm passen.";
|
||||
"Opening audio stream..." = "Audiostream wird geöffnet …";
|
||||
"Opening audio stream…" = "Audiostream wird geöffnet …";
|
||||
"Orientation" = "Ausrichtung";
|
||||
"Playlist \"%@\" will be deleted.\nIt cannot be reverted." = "Die Wiedergabeliste \"%@\" wird gelöscht.\nDies kann nicht rückgängig gemacht werden.";
|
||||
"Preferred Formats" = "Bevorzugte Formate";
|
||||
@ -569,7 +569,7 @@
|
||||
"Enter location address to connect..." = "Geben Sie die Internetadresse ein, um eine Verbindung herzustellen …";
|
||||
"Opened File" = "Geöffnete Datei";
|
||||
"File Extension" = "Dateierweiterung";
|
||||
"Opening file..." = "Datei öffnen …";
|
||||
"Opening file…" = "Datei öffnen …";
|
||||
"Close video and player on end" = "Video und Player am Ende beenden";
|
||||
"Use system controls with AVPlayer" = "Systemsteuerung mit AVPlayer verwenden";
|
||||
"Public account" = "Öffentliches Konto";
|
||||
|
@ -157,7 +157,7 @@
|
||||
"LIVE" = "LIVE";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Loading streams..." = "Loading streams...";
|
||||
"Loading streams…" = "Loading streams…";
|
||||
"Loading..." = "Loading...";
|
||||
"Locations" = "Locations";
|
||||
"Lock portrait mode" = "Lock portrait mode";
|
||||
@ -202,8 +202,8 @@
|
||||
"Open Settings" = "Open Settings";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Opening %@ stream..." = "Opening %@ stream...";
|
||||
"Opening audio stream..." = "Opening audio stream...";
|
||||
"Opening %@ stream…" = "Opening %@ stream…";
|
||||
"Opening audio stream…" = "Opening audio stream…";
|
||||
"Orientation" = "Orientation";
|
||||
|
||||
/* SponsorBlock category name */
|
||||
@ -567,7 +567,7 @@
|
||||
"Seek" = "Seek";
|
||||
"Opened File" = "Opened File";
|
||||
"File Extension" = "File Extension";
|
||||
"Opening file..." = "Opening file...";
|
||||
"Opening file…" = "Opening file…";
|
||||
"Public account" = "Public account";
|
||||
"Your Accounts" = "Your Accounts";
|
||||
"Browse without account" = "Browse without account";
|
||||
|
@ -138,7 +138,7 @@
|
||||
|
||||
/* Video date filter in search */
|
||||
"Today" = "Hoy";
|
||||
"Opening audio stream..." = "Abriendo transmisión de audio...";
|
||||
"Opening audio stream…" = "Abriendo transmisión de audio…";
|
||||
"Open Video" = "Abrir Video";
|
||||
"I want to ask a question" = "Quiero hacer una pregunta";
|
||||
"Save history of played videos" = "Guardar historial de videos reproducidos";
|
||||
@ -208,7 +208,7 @@
|
||||
"No documents" = "Sin documentos";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Opening %@ stream..." = "Abriendo %@ emisión...";
|
||||
"Opening %@ stream…" = "Abriendo %@ emisión…";
|
||||
"Documents" = "Documentos";
|
||||
"Thumbnails" = "Miniaturas";
|
||||
"Password" = "Contraseña";
|
||||
@ -265,7 +265,7 @@
|
||||
"Shuffle" = "Mezclar";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Loading streams..." = "Cargando secuencias...";
|
||||
"Loading streams…" = "Cargando secuencias…";
|
||||
"Public Locations" = "Ubicaciones públicas";
|
||||
"Yattee" = "Yattee";
|
||||
"No results" = "No hay resultados";
|
||||
@ -558,7 +558,7 @@
|
||||
"Available" = "Disponible";
|
||||
"Loop one" = "Bucle uno";
|
||||
"Use system controls with AVPlayer" = "Utilizar los controles del sistema con AVPlayer";
|
||||
"Opening file..." = "Abriendo el archivo...";
|
||||
"Opening file…" = "Abriendo el archivo…";
|
||||
"No videos to show" = "No hay vídeos que mostrar";
|
||||
"Autoplay next" = "Reproducir automáticamente la siguiente";
|
||||
"Home Settings" = "Ajustes iniciales";
|
||||
|
@ -83,7 +83,7 @@
|
||||
"Profiles" = "نمایهها";
|
||||
"New Playlist" = "فهرست پخش جدید";
|
||||
"Automatic" = "خودکار";
|
||||
"Opening file..." = "در حال باز کردن فایل…";
|
||||
"Opening file…" = "در حال باز کردن فایل…";
|
||||
"Add Quality Profile" = "افزودن نمایهٔ کیفیت";
|
||||
"Close video after playing last in the queue" = "ویدیو را پس از پخش آخرین مورد فهرست ببند";
|
||||
|
||||
@ -279,14 +279,14 @@
|
||||
"Controls" = "کنترلها";
|
||||
"This URL could not be opened" = "این نشانی باز نمیشود";
|
||||
"Trending" = "پرطرفدار";
|
||||
"Opening audio stream..." = "باز کردن استریم صوتی…";
|
||||
"Opening audio stream…" = "باز کردن استریم صوتی…";
|
||||
"Statistics" = "آمار";
|
||||
"Pause when player is closed" = "پس از بسته شدن پخشکننده مکث کن";
|
||||
"Play All" = "همه را پخش کن";
|
||||
"Sort: %@" = "ترتیب: %@";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Opening %@ stream..." = "باز کردن استریم %@…";
|
||||
"Opening %@ stream…" = "باز کردن استریم %@…";
|
||||
"Next in Queue" = "مورد بعد در صف";
|
||||
"Honor orientation lock" = "قفل چرخش صفحه را در نظر بگیر";
|
||||
"Rate" = "امتیاز";
|
||||
@ -405,7 +405,7 @@
|
||||
"Info" = "اطلاعات";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Loading streams..." = "درحال دریافت استریم…";
|
||||
"Loading streams…" = "درحال دریافت استریم…";
|
||||
"No rotation" = "بدون چرخش";
|
||||
"Codec" = "کدک (Codec)";
|
||||
"Startup section" = "بخش آغازین";
|
||||
|
@ -282,11 +282,11 @@
|
||||
"Large layout is not suitable for all devices and using it may cause controls not to fit on the screen." = "Le grand format n'est pas adapté à tous les appareils et son utilisation peut empêcher certains contrôles de s'afficher à l'écran.";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Loading streams..." = "Chargement des flux...";
|
||||
"Loading streams…" = "Chargement des flux…";
|
||||
"Lock portrait mode" = "Verrouille l'orientation en mode portrait";
|
||||
"Matrix Channel" = "Salon Matrix";
|
||||
"Only when signed in" = "Uniquement lorsque vous êtes connecté";
|
||||
"Opening audio stream..." = "Ouverture du flux audio…";
|
||||
"Opening audio stream…" = "Ouverture du flux audio…";
|
||||
"Orientation" = "Orientation";
|
||||
|
||||
/* SponsorBlock category name */
|
||||
@ -424,7 +424,7 @@
|
||||
"Milestones" = "Étapes";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Opening %@ stream..." = "Ouverture du flux %@…";
|
||||
"Opening %@ stream…" = "Ouverture du flux %@…";
|
||||
"Regular size" = "Taille normale";
|
||||
"Regular Size" = "Taille normale";
|
||||
"Related" = "En relation";
|
||||
@ -567,7 +567,7 @@
|
||||
"Seek" = "Recherche";
|
||||
"Show scroll to top button in comments" = "Afficher le bouton de retour en haut de la page dans les commentaires";
|
||||
"Opened File" = "Fichier ouvert";
|
||||
"Opening file..." = "Ouverture du fichier...";
|
||||
"Opening file…" = "Ouverture du fichier…";
|
||||
"Enter location address to connect..." = "Entrez l'adresse de l'instance pour se connecter...";
|
||||
"File Extension" = "Extension de fichier";
|
||||
"Public account" = "Compte publique";
|
||||
|
@ -84,8 +84,8 @@
|
||||
"Open Settings" = "सेटिंग खोलें";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Opening %@ stream..." = "%@ स्ट्रीम खुल रहा…";
|
||||
"Opening audio stream..." = "ऑडियो स्ट्रीम खुल रहा…";
|
||||
"Opening %@ stream…" = "%@ स्ट्रीम खुल रहा…";
|
||||
"Opening audio stream…" = "ऑडियो स्ट्रीम खुल रहा…";
|
||||
"If you are reporting a bug, include all relevant details (especially: app version, used device and system version, steps to reproduce)." = "यदि आप किसी बग की रिपोर्ट कर रहे हैं, तो सभी प्रासंगिक विवरण शामिल करें (विशेषकर: ऐप संस्करण, प्रयुक्त डिवाइस और सिस्टम संस्करण, पुन: पेश करने के चरण)।";
|
||||
"Increase rate" = "दर बढ़ाएँ";
|
||||
"Info" = "जानकारी";
|
||||
@ -104,7 +104,7 @@
|
||||
"LIVE" = "लाइव";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Loading streams..." = "स्ट्रीम लोड हो रहें…";
|
||||
"Loading streams…" = "स्ट्रीम लोड हो रहें…";
|
||||
"Loading..." = "लोड हो रहा…";
|
||||
"Locations" = "स्थान";
|
||||
"Lock portrait mode" = "पोर्ट्रेट मोड लॉक करें";
|
||||
|
@ -158,8 +158,8 @@
|
||||
"Open Settings" = "Apri Impostazioni";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Opening %@ stream..." = "Apertura stream %@...";
|
||||
"Opening audio stream..." = "Apertura stream audio...";
|
||||
"Opening %@ stream…" = "Apertura stream %@…";
|
||||
"Opening audio stream…" = "Apertura stream audio…";
|
||||
"Orientation" = "Orientamento";
|
||||
|
||||
/* SponsorBlock category name */
|
||||
@ -170,7 +170,7 @@
|
||||
"LIVE" = "DIRETTA";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Loading streams..." = "Caricamento stream...";
|
||||
"Loading streams…" = "Caricamento stream…";
|
||||
"Loading..." = "Caricamento...";
|
||||
"Locations" = "Posizioni";
|
||||
"Low quality" = "Qualità bassa";
|
||||
@ -569,7 +569,7 @@
|
||||
"Enter location address to connect..." = "Inserisci posizione per connetterti...";
|
||||
"Opened File" = "File aperto";
|
||||
"File Extension" = "Estensione file";
|
||||
"Opening file..." = "Apro file...";
|
||||
"Opening file…" = "Apro file…";
|
||||
"Close video and player on end" = "Chiudi video e riproduttore alla fine";
|
||||
"Use system controls with AVPlayer" = "Usa controlli di sistema con AVPlayer";
|
||||
"Public account" = "Account pubblico";
|
||||
|
@ -87,7 +87,7 @@
|
||||
"Large" = "大";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Loading streams..." = "ストリーム読込中...";
|
||||
"Loading streams…" = "ストリーム読込中…";
|
||||
"Lock portrait mode" = "縦モードをロック";
|
||||
"LIVE" = "ライブ";
|
||||
"Locations" = "場所";
|
||||
@ -108,10 +108,10 @@
|
||||
"Offtopic in Music Videos" = "音楽動画の非音楽部分";
|
||||
"Only when signed in" = "ログイン時のみ";
|
||||
"Orientation" = "向き";
|
||||
"Opening audio stream..." = "音声ストリーム 開始中...";
|
||||
"Opening audio stream…" = "音声ストリーム 開始中…";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Opening %@ stream..." = "%@ ストリーム 開始中...";
|
||||
"Opening %@ stream…" = "%@ ストリーム 開始中…";
|
||||
|
||||
/* SponsorBlock category name */
|
||||
"Outro" = "終了シーン";
|
||||
@ -566,7 +566,7 @@
|
||||
"Show scroll to top button in comments" = "コメント欄に「上に戻る」表示";
|
||||
"Opened File" = "開いたファイル";
|
||||
"File Extension" = "ファイル拡張子";
|
||||
"Opening file..." = "ファイルを読み込み中...";
|
||||
"Opening file…" = "ファイルを読み込み中…";
|
||||
"Your Accounts" = "アカウントを使用";
|
||||
"Public account" = "公開アカウント";
|
||||
"Browse without account" = "アカウントなしで閲覧";
|
||||
|
@ -82,7 +82,7 @@
|
||||
"LIVE" = "Direkte";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Loading streams..." = "Laster inn strømmer …";
|
||||
"Loading streams…" = "Laster inn strømmer …";
|
||||
|
||||
/* Video duration filter in search */
|
||||
"Long" = "Lang";
|
||||
@ -223,7 +223,7 @@
|
||||
"Rate" = "Takt";
|
||||
"Not Playing" = "Spiller ikke";
|
||||
"Open \"Playlists\" tab to create new one" = "Åpne «Spillelister»-fanen for å opprette ny";
|
||||
"Opening audio stream..." = "Åpner lydstrøm …";
|
||||
"Opening audio stream…" = "Åpner lydstrøm …";
|
||||
"Password" = "Passord";
|
||||
"Nothing" = "Ingenting";
|
||||
"Picture in Picture" = "Bilde-i-bilde";
|
||||
@ -303,7 +303,7 @@
|
||||
"Offtopic in Music Videos" = "Urelaterte ting i musikkvideoer";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Opening %@ stream..." = "Åpner %@-strøm …";
|
||||
"Opening %@ stream…" = "Åpner %@-strøm …";
|
||||
"Play in PiP" = "Bilde-i-bilde";
|
||||
"Pause when entering background" = "Pause ved forsendelse til bakgrunnen";
|
||||
"Promoting a product or service that is directly related to the creator themselves. This usually includes merchandise or promotion of monetized platforms." = "Promoterer noe som har å gjøre med skaperen direkte. Vanligvis effekter eller betalte plattformer.";
|
||||
|
@ -157,7 +157,7 @@
|
||||
"LIVE" = "LIVE";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Loading streams..." = "Ładowanie strumieni...";
|
||||
"Loading streams…" = "Ładowanie strumieni…";
|
||||
"Loading..." = "Ładowanie…";
|
||||
"Locations" = "Lokalizacje";
|
||||
"Lock portrait mode" = "Zablokuj tryb portretowy";
|
||||
@ -202,8 +202,8 @@
|
||||
"Open Settings" = "Otwórz Ustawienia";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Opening %@ stream..." = "Otwieranie strumienia %@…";
|
||||
"Opening audio stream..." = "Otwieranie strumienia audio...";
|
||||
"Opening %@ stream…" = "Otwieranie strumienia %@…";
|
||||
"Opening audio stream…" = "Otwieranie strumienia audio…";
|
||||
"Orientation" = "Orientacja";
|
||||
|
||||
/* SponsorBlock category name */
|
||||
@ -570,7 +570,7 @@
|
||||
"Enter location address to connect..." = "Wprowadź adres lokalizacji, aby połączyć...";
|
||||
"Opened File" = "Otwarty plik";
|
||||
"File Extension" = "Rozszerzenie pliku";
|
||||
"Opening file..." = "Otwieranie pliku...";
|
||||
"Opening file…" = "Otwieranie pliku…";
|
||||
"Public account" = "Konto publiczne";
|
||||
"Your Accounts" = "Twoje konta";
|
||||
"Browse without account" = "Przeglądanie bez konta";
|
||||
|
@ -102,7 +102,7 @@
|
||||
"Just watched" = "Acabou de assistir";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Loading streams..." = "Carregando streams…";
|
||||
"Loading streams…" = "Carregando streams…";
|
||||
"Medium quality" = "Qualidade média";
|
||||
"No description" = "Sem descrição";
|
||||
"No Playlists" = "Sem playlists";
|
||||
@ -114,8 +114,8 @@
|
||||
"Open Settings" = "Abrir Ajustes";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Opening %@ stream..." = "Abrindo stream %@…";
|
||||
"Opening audio stream..." = "Abrindo stream de áudio…";
|
||||
"Opening %@ stream…" = "Abrindo stream %@…";
|
||||
"Opening audio stream…" = "Abrindo stream de áudio…";
|
||||
"Orientation" = "Orientação";
|
||||
|
||||
/* SponsorBlock category name */
|
||||
@ -569,7 +569,7 @@
|
||||
"Enter account credentials to connect..." = "Insira as credenciais da conta para conectar…";
|
||||
"Opened File" = "Arquivo Aberto";
|
||||
"File Extension" = "Extensão do Arquivo";
|
||||
"Opening file..." = "Abrindo arquivo…";
|
||||
"Opening file…" = "Abrindo arquivo…";
|
||||
"Browse without account" = "Navegar sem uma conta";
|
||||
"Rotate when entering fullscreen on landscape video" = "Girar quando entrar no modo tela cheia em vídeo em paisagem";
|
||||
"Landscape left" = "Paisagem à esquerda";
|
||||
|
@ -196,7 +196,7 @@
|
||||
"LIVE" = "AO VIVO";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Loading streams..." = "Carregando streams…";
|
||||
"Loading streams…" = "Carregando streams…";
|
||||
"Loading..." = "Carregando…";
|
||||
"Locations" = "Localizações";
|
||||
"Lock portrait mode" = "Travar modo retrato";
|
||||
@ -237,8 +237,8 @@
|
||||
"Open Settings" = "Abrir Ajustes";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Opening %@ stream..." = "Abrindo stream %@…";
|
||||
"Opening audio stream..." = "Abrindo stream de áudio…";
|
||||
"Opening %@ stream…" = "Abrindo stream %@…";
|
||||
"Opening audio stream…" = "Abrindo stream de áudio…";
|
||||
"Orientation" = "Orientação";
|
||||
|
||||
/* SponsorBlock category name */
|
||||
@ -557,7 +557,7 @@
|
||||
"Keep channels with unwatched videos on top of subscriptions list" = "Manter canais com vídeos não vistos no topo da lista de inscrições";
|
||||
"Opened File" = "Ficheiro Aberto";
|
||||
"File Extension" = "Extensão do Ficheiro";
|
||||
"Opening file..." = "A abrir ficheiro…";
|
||||
"Opening file…" = "A abrir ficheiro…";
|
||||
"Close video and player on end" = "Fechar vídeo e player ao final";
|
||||
"Use system controls with AVPlayer" = "Usar controles do sistema com o AVPlayer";
|
||||
"Public account" = "Conta pública";
|
||||
|
@ -89,7 +89,7 @@
|
||||
"LIVE" = "LIVE";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Loading streams..." = "Se încarcă fluxurile...";
|
||||
"Loading streams…" = "Se încarcă fluxurile…";
|
||||
"Locations" = "Locații";
|
||||
"Mark watched videos with" = "Marcați videoclipurile vizionate cu";
|
||||
"Matrix Channel" = "Canal Matrix";
|
||||
@ -100,7 +100,7 @@
|
||||
"Nothing" = "Nimic";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Opening %@ stream..." = "Se deschide %@ flux...";
|
||||
"Opening %@ stream…" = "Se deschide %@ flux…";
|
||||
"Play Last" = "Reda ultimul";
|
||||
"Player" = "Player";
|
||||
"Playlist" = "Playlist";
|
||||
@ -243,7 +243,7 @@
|
||||
/* SponsorBlock category name */
|
||||
"Outro" = "Outro";
|
||||
"Orientation" = "Orientare";
|
||||
"Opening audio stream..." = "Se deschide fluxul audio...";
|
||||
"Opening audio stream…" = "Se deschide fluxul audio…";
|
||||
"Password" = "Parolă";
|
||||
"Pause" = "Pauză";
|
||||
"Pause when entering background" = "Pauză când intrați în fundal";
|
||||
@ -568,7 +568,7 @@
|
||||
"Enter account credentials to connect..." = "Introduceți acreditările contului pentru a vă conecta...";
|
||||
"Enter location address to connect..." = "Introdu adresa locației pentru a te conecta...";
|
||||
"Opened File" = "Fișier deschis";
|
||||
"Opening file..." = "Deschiderea fișierului...";
|
||||
"Opening file…" = "Deschiderea fișierului…";
|
||||
"File Extension" = "Extensie fișier";
|
||||
"Use system controls with AVPlayer" = "Utilizați controalele de sistem cu AVPlayer";
|
||||
"Rotate when entering fullscreen on landscape video" = "Rotiți când intrați pe ecran complet în videoclipul peisaj";
|
||||
|
@ -370,7 +370,7 @@
|
||||
"LIVE" = "ПРЯМАЯ ТРАНСЛЯЦИЯ";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Loading streams..." = "Загрузка прямой трансляции...";
|
||||
"Loading streams…" = "Загрузка прямой трансляции…";
|
||||
"Loading..." = "Загрузка...";
|
||||
"Lock portrait mode" = "Блокировка портретного режима";
|
||||
"Low" = "Низкое";
|
||||
@ -405,8 +405,8 @@
|
||||
"Open Settings" = "Отрыть настройки";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Opening %@ stream..." = "Открытие %@ прямой трансляции...";
|
||||
"Opening audio stream..." = "Открытие прямой трансляции аудио...";
|
||||
"Opening %@ stream…" = "Открытие %@ прямой трансляции…";
|
||||
"Opening audio stream…" = "Открытие прямой трансляции аудио…";
|
||||
"Orientation" = "Ориентация";
|
||||
|
||||
/* SponsorBlock category name */
|
||||
@ -591,7 +591,7 @@
|
||||
"Actions buttons" = "Кнопки действия";
|
||||
"Show sidebar" = "Показать боковую панель";
|
||||
"Browse without account" = "Искать без аккаунта";
|
||||
"Opening file..." = "Отрытие файла...";
|
||||
"Opening file…" = "Отрытие файла…";
|
||||
"Public account" = "Публичный аккаунт";
|
||||
"Your Accounts" = "Ваши аккаунты";
|
||||
"Close video and player on end" = "Закрыть видео и плеер в конце";
|
||||
|
@ -110,7 +110,7 @@
|
||||
"I want to ask a question" = "Bir soru sormak istiyorum";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Loading streams..." = "Akışlar yükleniyor...";
|
||||
"Loading streams…" = "Akışlar yükleniyor…";
|
||||
"Edit Quality Profile" = "Kalite Profilini Düzenle";
|
||||
"Frontend URL" = "Ön uç URL'si";
|
||||
"Close player when starting PiP" = "Resim içinde Resim modu başlatılırken oynatıcıyı kapat";
|
||||
@ -227,7 +227,7 @@
|
||||
"No description" = "Açıklama yok";
|
||||
"Normal" = "Normal";
|
||||
"Open \"Playlists\" tab to create new one" = "Yeni bir tane oluşturmak için \"Oynatma Listeleri\" sekmesini açın";
|
||||
"Opening audio stream..." = "Ses akışı açılıyor...";
|
||||
"Opening audio stream…" = "Ses akışı açılıyor…";
|
||||
"Rate" = "Derecelendir";
|
||||
"Orientation" = "Yönlendirme";
|
||||
"No results" = "Sonuç yok";
|
||||
@ -396,7 +396,7 @@
|
||||
"Open" = "Aç";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Opening %@ stream..." = "%@ akışı açılıyor...";
|
||||
"Opening %@ stream…" = "%@ akışı açılıyor…";
|
||||
"Clear Queue before opening" = "Açmadan önce Bekleme Listesini temizle";
|
||||
"Copy %@ link with time" = "Bağlantıyı %@ zaman ile kopyala";
|
||||
"Show Inspector" = "Denetleyiciyi Göster";
|
||||
|
@ -259,7 +259,7 @@
|
||||
"LIVE" = "В ЕФІРІ";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Loading streams..." = "Завантаження трансляції...";
|
||||
"Loading streams…" = "Завантаження трансляції…";
|
||||
"Loading..." = "Завантаження...";
|
||||
"Locations" = "Локації";
|
||||
"Lock portrait mode" = "Заблокувати портретний режим";
|
||||
@ -298,8 +298,8 @@
|
||||
"Open Settings" = "Відрити налаштування";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Opening %@ stream..." = "Запуск трансляції %@...";
|
||||
"Opening audio stream..." = "Запуск аудіо трансляції...";
|
||||
"Opening %@ stream…" = "Запуск трансляції %@…";
|
||||
"Opening audio stream…" = "Запуск аудіо трансляції…";
|
||||
"Orientation" = "Орієнтація";
|
||||
|
||||
/* SponsorBlock category name */
|
||||
|
@ -152,7 +152,7 @@
|
||||
"Loading..." = "加载中...";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Loading streams..." = "加载流中...";
|
||||
"Loading streams…" = "加载流中…";
|
||||
"Locations" = "地址";
|
||||
"Lock portrait mode" = "锁定竖屏模式";
|
||||
|
||||
@ -191,8 +191,8 @@
|
||||
"Open Settings" = "打开设置";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Opening %@ stream..." = "正在打开 %@ 的流...";
|
||||
"Opening audio stream..." = "正在打开音频流...";
|
||||
"Opening %@ stream…" = "正在打开 %@ 的流…";
|
||||
"Opening audio stream…" = "正在打开音频流…";
|
||||
"Orientation" = "方向";
|
||||
|
||||
/* SponsorBlock category name */
|
||||
@ -530,7 +530,7 @@
|
||||
"Show scroll to top button in comments" = "在评论中显示“滚动到顶部”按钮";
|
||||
"Opened File" = "打开的文件";
|
||||
"File Extension" = "文件扩展";
|
||||
"Opening file..." = "打开文件中...";
|
||||
"Opening file…" = "打开文件中…";
|
||||
"Single tap gesture" = "单击手势";
|
||||
"Right click channel thumbnail to open context menu with more actions" = "右键单击频道缩略图以打开具有更多操作的上下文菜单";
|
||||
"Show unwatched feed badges" = "显示未观看的 Feed 标志";
|
||||
|
@ -252,7 +252,7 @@
|
||||
"Interface" = "介面";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Loading streams..." = "加載中...";
|
||||
"Loading streams…" = "加載中…";
|
||||
"Loading..." = "加載中...";
|
||||
"Locations" = "地址";
|
||||
"Lock portrait mode" = "鎖定直屏";
|
||||
@ -292,8 +292,8 @@
|
||||
"Open Settings" = "打開設置";
|
||||
|
||||
/* Loading stream OSD */
|
||||
"Opening %@ stream..." = "正在打開 %@ ...";
|
||||
"Opening audio stream..." = "正在打開音訊...";
|
||||
"Opening %@ stream…" = "正在打開 %@ …";
|
||||
"Opening audio stream…" = "正在打開音訊…";
|
||||
|
||||
/* SponsorBlock category name */
|
||||
"Outro" = "結尾";
|
||||
@ -554,7 +554,7 @@
|
||||
"Queue - shuffled" = "隊列 - 隨機";
|
||||
"Loop one" = "單個循環";
|
||||
"File Extension" = "副檔名";
|
||||
"Opening file..." = "正在打開文件...";
|
||||
"Opening file…" = "正在打開文件…";
|
||||
"Public account" = "公共帳戶";
|
||||
"Enter account credentials to connect..." = "輸入帳號密碼來連接...";
|
||||
"Enter location address to connect..." = "輸入站台地址來連接...";
|
||||
|
@ -1070,6 +1070,9 @@
|
||||
37FFC440272734C3009FFD26 /* Throttle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FFC43F272734C3009FFD26 /* Throttle.swift */; };
|
||||
37FFC441272734C3009FFD26 /* Throttle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FFC43F272734C3009FFD26 /* Throttle.swift */; };
|
||||
37FFC442272734C3009FFD26 /* Throttle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FFC43F272734C3009FFD26 /* Throttle.swift */; };
|
||||
E258F38A2BF61BD2005B8C28 /* URLTester.swift in Sources */ = {isa = PBXBuildFile; fileRef = E258F3892BF61BD2005B8C28 /* URLTester.swift */; };
|
||||
E258F38B2BF61BD2005B8C28 /* URLTester.swift in Sources */ = {isa = PBXBuildFile; fileRef = E258F3892BF61BD2005B8C28 /* URLTester.swift */; };
|
||||
E258F38C2BF61BD2005B8C28 /* URLTester.swift in Sources */ = {isa = PBXBuildFile; fileRef = E258F3892BF61BD2005B8C28 /* URLTester.swift */; };
|
||||
FA97174C2A494700001FF53D /* MPVKit in Frameworks */ = {isa = PBXBuildFile; productRef = FA97174B2A494700001FF53D /* MPVKit */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@ -1539,6 +1542,7 @@
|
||||
3DA101AD287C30F50027D920 /* DEVELOPMENT_TEAM.template.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = DEVELOPMENT_TEAM.template.xcconfig; sourceTree = "<group>"; };
|
||||
3DA101AE287C30F50027D920 /* DEVELOPMENT_TEAM.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = DEVELOPMENT_TEAM.xcconfig; sourceTree = "<group>"; };
|
||||
3DA101AF287C30F50027D920 /* Shared.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Shared.xcconfig; sourceTree = "<group>"; };
|
||||
E258F3892BF61BD2005B8C28 /* URLTester.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLTester.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@ -2293,6 +2297,7 @@
|
||||
3700155E271B12DD0049C794 /* SiestaConfiguration.swift */,
|
||||
37FFC43F272734C3009FFD26 /* Throttle.swift */,
|
||||
378FFBC328660172009E3FBE /* URLParser.swift */,
|
||||
E258F3892BF61BD2005B8C28 /* URLTester.swift */,
|
||||
37D4B0C22671614700C925CA /* YatteeApp.swift */,
|
||||
37D4B0C42671614800C925CA /* Assets.xcassets */,
|
||||
37BD07C42698ADEE003EBB87 /* Yattee.entitlements */,
|
||||
@ -3138,6 +3143,7 @@
|
||||
377FC7DC267A081800A6BBAF /* PopularView.swift in Sources */,
|
||||
3752069D285E910600CA655F /* ChapterView.swift in Sources */,
|
||||
375EC96A289F232600751258 /* QualityProfilesModel.swift in Sources */,
|
||||
E258F38A2BF61BD2005B8C28 /* URLTester.swift in Sources */,
|
||||
3751B4B227836902000B7DF4 /* SearchPage.swift in Sources */,
|
||||
37CC3F4C270CFE1700608308 /* PlayerQueueView.swift in Sources */,
|
||||
37FFC440272734C3009FFD26 /* Throttle.swift in Sources */,
|
||||
@ -3621,6 +3627,7 @@
|
||||
378E9C39294552A700B2D696 /* ThumbnailView.swift in Sources */,
|
||||
370F4FAB27CC164D001B35DC /* PlayerControlsModel.swift in Sources */,
|
||||
37E8B0ED27B326C00024006F /* TimelineView.swift in Sources */,
|
||||
E258F38B2BF61BD2005B8C28 /* URLTester.swift in Sources */,
|
||||
370E990B2A1EA8C500D144E9 /* WatchModel.swift in Sources */,
|
||||
3717407E2949D40800FDDBC7 /* ChannelLinkView.swift in Sources */,
|
||||
37FB28422721B22200A57617 /* ContentItem.swift in Sources */,
|
||||
@ -3908,6 +3915,7 @@
|
||||
37B2631C2735EAAB00FE0D40 /* FavoriteResourceObserver.swift in Sources */,
|
||||
37484C2B26FC83FF00287258 /* AccountForm.swift in Sources */,
|
||||
37FB2860272225E800A57617 /* ContentItemView.swift in Sources */,
|
||||
E258F38C2BF61BD2005B8C28 /* URLTester.swift in Sources */,
|
||||
37F7D82E289EB05F00E2B3D0 /* SettingsPickerModifier.swift in Sources */,
|
||||
374AB3DD28BCAF7E00DF56FB /* SeekType.swift in Sources */,
|
||||
374C053727242D9F009BDDBE /* SponsorBlockSettings.swift in Sources */,
|
||||
|
Loading…
Reference in New Issue
Block a user