【问题标题】:Start cast session for a cast device开始投射设备的投射会话
【发布时间】:2018-03-19 19:36:04
【问题描述】:

我有这个用例:

  1. 检测投射设备并保存其 ID、名称和信息;
  2. 以自动方式连接到预定义的设备并开始投射会话 有一些内容。

我研究了 Google Cast API v3,看起来真的很难。虽然使用 v2 是可能的,因为发送方应用程序控制了 90% 的过程,即与设备的连接和加载内容,而使用 v3,会话完全由框架管理,会话仅在用户干预的情况下启动。对于我的用例来说,唯一值得使用的方法是SessionManager.startSession(Intent intent) doc here,但是完全没有记录如何使用意图、额外参数、操作等。有没有人对这种方法和意图有所了解?

【问题讨论】:

    标签: android google-cast google-cast-sdk


    【解决方案1】:

    我最近也有同样的要求。

    您可以使用MediaRouter 检测投射设备。

    MediaRouter mMediaRouter = MediaRouter.getInstance(this);
    MediaRouteSelector mMediaRouteSelector = new MediaRouteSelector.Builder()
                .addControlCategory(CastMediaControlIntent.categoryForCast(getString(R.string.cast_app_id)))
                .build();
    mMediaRouter.addCallback(mMediaRouterCallback, mMediaRouterCallback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY);
    
    // Then get your media routes using 
    List<RouteInfo> routes = mMediaRouter.getRoutes()
    
    // Get cast devices for your media routes.
    // Save these for future use as per your use case
    List<CastDevice> castDevices = routes.stream()
        .map(route -> CastDevice.getFromBundle(route.getExtras()))
        .collect(Collectors.toCollection())
    

    要自动连接到投射设备并流式传输某些内容,请使用此 sn-p。请注意,您可能无法使用 RemoteMediaPlayer,具体取决于您的接收器应用程序。这个 sn-p 对我有用,因为我的接收器应用程序使用 MediaManager

    // Connect to the cast device you want to stream the content to
    private void connectToCastDevice(CastDevice castDevice) {
        Cast.CastOptions apiOptions = Cast.CastOptions.builder(castDevice, mCastListener).build();
        mApiClient = new GoogleApiClient.Builder(this)
                .addApi(Cast.API, apiOptions)
                .addConnectionCallbacks(mConnectionCallback)
                .addOnConnectionFailedListener(mConnectionFailedListener)
                .build();
        mApiClient.connect();
    }
    
    // After you are connected to the cast device. Load your media to it
    // In my case using RemoteMediaPlayer
    private void loadMediaItem(final MediaInfo mediaInfo) {
        LaunchOptions launchOptions = new LaunchOptions();
        launchOptions.setRelaunchIfRunning(false);
    
        PendingResult<Cast.ApplicationConnectionResult> result = Cast.CastApi.launchApplication(mApiClient, getString(R.string.cast_app_id), launchOptions);
    
        result.then(new ResultTransform<Cast.ApplicationConnectionResult, RemoteMediaPlayer.MediaChannelResult>() {
    
            @Nullable @Override
            public PendingResult<RemoteMediaPlayer.MediaChannelResult> onSuccess(@NonNull Cast.ApplicationConnectionResult applicationConnectionResult) {
                Log.d(TAG, "Application launch result: " + applicationConnectionResult);
                return mRemoteMediaPlayer.load(mApiClient, mediaInfo);
            }
    
        }).andFinally(new ResultCallbacks<RemoteMediaPlayer.MediaChannelResult>() {
    
            @Override
            public void onSuccess(@NonNull RemoteMediaPlayer.MediaChannelResult mediaChannelResult) {
                Log.d(TAG, "Media channel result: " + mediaChannelResult);
            }
    
            @Override
            public void onFailure(@NonNull Status status) {
                Log.d(TAG, "Media channel status: " + status);
            }
    
        });
    }
    

    【讨论】:

    • 您正在使用已弃用的代码,不再支持 v2,并且此答案未回答我的问题。
    • 你可以用这个start a sessionSample code。请注意,如果在播放媒体时未指定会话 ID,playing a media 会创建一个新会话。
    • 您可以选择答案中提到的媒体路由,然后使用RemotePlayBack客户端为您管理会话。
    【解决方案2】:

    我找到了另一种不使用意图但使用路由连接到投射设备的方法。

    所以第一步是使用CastOptionsProvider 类和转换上下文初始化转换。获取设备的第二步,最后一步通过传递您在第二步检索到的所选设备的路由连接到投射设备:

    MediaRouter.getInstance(activity).selectRoute(route);

    【讨论】:

      【解决方案3】:

      TLDR;跳至步骤 3 - 选项 1 (SessionManager.startSession) 或步骤 3 - 选项 2 (MediaRouter.selectRoute)

      第 1 步 - 设置

      像往常一样设置 CastOptionsProvider。

      以下是我们将使用的主要对象:

      MediaRouter mediaRouter = MediaRouter.getInstance(activity);
      CastContex context = CastContext.getSharedInstance(activity);
      SessionManager sessionManager = context.getSessionManager();
      

      第 2 步 - 检索路由(设备)以进行保存/使用

      获取路由/设备 ID

      第 2 步 - 选项 1 - 当前缓存路由

      只获取当前缓存的路由:

      for (RouteInfo route : mediaRouter.getRoutes()) {
          // Save route.getId(); however you want (it's a string)
      }
      

      缺点:返回的路线可能已经过时了。 MediaRouter 的路由缓存仅在触发扫描时更新(由您手动或由演员库)。

      第 2 步 - 选项 2 - 主动扫描

      主动扫描最准确的路线列表:

      MediaRouter.Callback callback = new MediaRouter.Callback() {
          private void updateMyRouteList() {
              for (RouteInfo route : mediaRouter.getRoutes()) {
                  // Save route.getId() however you want (it's a string)
              }
          }
          @Override
          public void onRouteAdded(MediaRouter router, RouteInfo route) {
              updateMyRouteList();
          }
      
          @Override
          public void onRouteRemoved(MediaRouter router, RouteInfo route) {
              updateMyRouteList();
          }
          @Override
          public void onRouteChanged(MediaRouter router, RouteInfo route) {
              updateMyRouteList();
          }
      };
      mediaRouter.addCallback(new MediaRouteSelector.Builder()
                      .addControlCategory(CastMediaControlIntent.categoryForCast(appId))
                      .build(),
              callback,
              MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN);
      

      注意!请务必停止主动扫描,否则电池会很快耗尽!你停止扫描

      mediaRouter.removeCallback(callback);
      

      第 2 步 - 选项 3 - 被动扫描

      选项 2 相同,但省略 mediaRouter.addCallbackflags 参数。
      应该(我认为)被动地监听路由变化。 (尽管您可能没有比 选项 1 更好的结果)。例如:

      mediaRouter.addCallback(new MediaRouteSelector.Builder()
                      .addControlCategory(CastMediaControlIntent.categoryForCast(appId))
                      .build(),
              callback);
      

      第 3 步 - 加入路由(设备)

      如何以编程方式加入路由(设备)。 有 2 个主要选项。

      这两个选项要么创建一个新会话,要么加入您尝试加入的设备上的现有会话(如果 appId 相同)。

      首先,准备

      // Optional - if your app changes receiverApplicationId on the fly you should change that here
      context.setReceiverApplicationId(appId);
      // Most people would just set this as a constant in their CastOptionsProvider
      
      // Listen for a successful join
      sessionManager.addSessionManagerListener(new SessionManagerListener<Session>() {
          @Override
          public void onSessionStarted(CastSession castSession, String sessionId) { 
              // We successfully joined a route(device)!
          }
      });
      

      现在,考虑到我们从第 2 步

      获得的routeId,现在,如何实际加入路线

      第 3 步 - 选项 1 - SessionManager.startSession

      注意:我发现此方法不适用于我的 Android 4.4 设备。我收到 SessionManagerListener.onSessionStartFailed 错误 15(超时)。
      不过,它确实可以在我的 Android 7.0 设备上运行。

      // Create the intent
      Intent castIntent = new Intent();
      // Mandatory, if null, nothing will happen
      castIntent.putExtra("CAST_INTENT_TO_CAST_ROUTE_ID_KEY", routeId);
      // (Optional) Uses this name in the toast
      castIntent.putExtra("CAST_INTENT_TO_CAST_DEVICE_NAME_KEY", route.getName());
      // Optional - false = displays "Connecting to <devicename>..."
      castIntent.putExtra("CAST_INTENT_TO_CAST_NO_TOAST_KEY", true);
      
      sessionManager.startSession(castIntent);
      

      第 3 步 - 选项 2 - MediaRouter.selectRoute

      要使用此选项,您必须拥有完整的 Route 对象,而不仅仅是 id 字符串。
      如果您已经拥有该对象,那就太好了!
      如果没有,您可以使用Step 2 - Option2 - Active Scan中的方法通过查找匹配的id来获取Route对象。

      mediaRouter.selectRoute(routeObject);
      

      第 4 步 - 流式传输内容

      完成第 3 步准备的课程后,艰苦的工作就完成了。
      您可以使用RemoteMediaClient 来控制投射的内容。

      RemoteMediaClient remoteMediaClient = castSession.getRemoteMediaClient();
      remoteMediaClient.load(...);
      

      完整代码

      我将把它包括在内,因为我花了很多时间来解决会话问题,希望它可以使其他人受益。 (包括 Android 4.4/Slow 设备上的间歇性计时和崩溃问题[不确定是哪一个问题的根源]。

      里面可能有一些额外的东西(特别是如果你使用一个常量 appId,initialize 将无关紧要),所以请使用你需要的东西。

      最相关的方法是selectRoute,它接受一个 routeId 字符串并会主动扫描最多 15 秒的匹配项。它还处理一些重试可能起作用的错误。

      你可以看到true full code here
      [下面的代码可能已经过时了。真正的完整代码是为在 Cordova 插件中使用而编写的。如果你想在你的应用程序中使用代码,删除 Cordova 依赖项是微不足道的。]

      public class ChromecastConnection {
      
          /** Lifetime variable. */
          private Activity activity;
          /** settings object. */
          private SharedPreferences settings;
      
          /** Lifetime variable. */
          private SessionListener newConnectionListener;
          /** The Listener callback. */
          private Listener listener;
      
          /** Initialize lifetime variable. */
          private String appId;
      
          /**
           * Constructor.  Call this in activity start.
           * @param act the current context
           * @param connectionListener client callbacks for specific events
           */
          ChromecastConnection(Activity act, Listener connectionListener) {
              this.activity = act;
              this.settings = activity.getSharedPreferences("CORDOVA-PLUGIN-CHROMECAST_ChromecastConnection", 0);
              this.appId = settings.getString("appId", CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID);
              this.listener = connectionListener;
      
              // Set the initial appId
              CastOptionsProvider.setAppId(appId);
      
              // This is the first call to getContext which will start up the
              // CastContext and prep it for searching for a session to rejoin
              // Also adds the receiver update callback
              getContext().addCastStateListener(listener);
          }
      
          /**
           * Must be called each time the appId changes and at least once before any other method is called.
           * @param applicationId the app id to use
           * @param callback called when initialization is complete
           */
          public void initialize(String applicationId, CallbackContext callback) {
              activity.runOnUiThread(new Runnable() {
                  public void run() {
      
                      // If the app Id changed, set it again
                      if (!applicationId.equals(appId)) {
                          setAppId(applicationId);
                      }
      
                      // Tell the client that initialization was a success
                      callback.success();
      
                      // Check if there is any available receivers for 5 seconds
                      startRouteScan(5000L, new ScanCallback() {
                          @Override
                          void onRouteUpdate(List<RouteInfo> routes) {
                              // if the routes have changed, we may have an available device
                              // If there is at least one device available
                              if (getContext().getCastState() != CastState.NO_DEVICES_AVAILABLE) {
                                  // Stop the scan
                                  stopRouteScan(this);
                                  // Let the client know a receiver is available
                                  listener.onReceiverAvailableUpdate(true);
                                  // Since we have a receiver we may also have an active session
                                  CastSession session = getSessionManager().getCurrentCastSession();
                                  // If we do have a session
                                  if (session != null) {
                                      // Let the client know
                                      listener.onSessionRejoin(session);
                                  }
                              }
                          }
                      }, null);
                  }
              });
          }
      
          private MediaRouter getMediaRouter() {
              return MediaRouter.getInstance(activity);
          }
      
          private CastContext getContext() {
              return CastContext.getSharedInstance(activity);
          }
      
          private SessionManager getSessionManager() {
              return getContext().getSessionManager();
          }
      
          private CastSession getSession() {
              return getSessionManager().getCurrentCastSession();
          }
      
          private void setAppId(String applicationId) {
              this.appId = applicationId;
              this.settings.edit().putString("appId", appId).apply();
              getContext().setReceiverApplicationId(appId);
          }
      
          /**
           * This will create a new session or seamlessly selectRoute an existing one if we created it.
           * @param routeId the id of the route to selectRoute
           * @param callback calls callback.onJoin when we have joined a session,
           *                 or callback.onError if an error occurred
           */
          public void selectRoute(final String routeId, SelectRouteCallback callback) {
              activity.runOnUiThread(new Runnable() {
                  public void run() {
                      if (getSession() != null && getSession().isConnected()) {
                          callback.onError(ChromecastUtilities.createError("session_error",
                                  "Leave or stop current session before attempting to join new session."));
                      }
      
                      // We need this hack so that we can access these values in callbacks without having
                      // to store it as a global variable, just always access first element
                      final boolean[] foundRoute = {false};
                      final boolean[] sentResult = {false};
                      final int[] retries = {0};
      
                      // We need to start an active scan because getMediaRouter().getRoutes() may be out
                      // of date.  Also, maintaining a list of known routes doesn't work.  It is possible
                      // to have a route in your "known" routes list, but is not in
                      // getMediaRouter().getRoutes() which will result in "Ignoring attempt to select
                      // removed route: ", even if that route *should* be available.  This state could
                      // happen because routes are periodically "removed" and "added", and if the last
                      // time media router was scanning ended when the route was temporarily removed the
                      // getRoutes() fn will have no record of the route.  We need the active scan to
                      // avoid this situation as well.  PS. Just running the scan non-stop is a poor idea
                      // since it will drain battery power quickly.
                      ScanCallback scan = new ScanCallback() {
                          @Override
                          void onRouteUpdate(List<RouteInfo> routes) {
                              // Look for the matching route
                              for (RouteInfo route : routes) {
                                  if (!foundRoute[0] && route.getId().equals(routeId)) {
                                      // Found the route!
                                      foundRoute[0] = true;
                                      // try-catch for issue:
                                      // https://github.com/jellyfin/cordova-plugin-chromecast/issues/48
                                      try {
                                          // Try selecting the route!
                                          getMediaRouter().selectRoute(route);
                                      } catch (NullPointerException e) {
                                          // Let it try to find the route again
                                          foundRoute[0] = false;
                                      }
                                  }
                              }
                          }
                      };
      
                      Runnable retry = new Runnable() {
                          @Override
                          public void run() {
                              // Reset foundRoute
                              foundRoute[0] = false;
                              // Feed current routes into scan so that it can retry.
                              // If route is there, it will try to join,
                              // if not, it should wait for the scan to find the route
                              scan.onRouteUpdate(getMediaRouter().getRoutes());
                          }
                      };
      
                      Function<JSONObject, Void> sendErrorResult = new Function<JSONObject, Void>() {
                          @Override
                          public Void apply(JSONObject message) {
                              if (!sentResult[0]) {
                                  sentResult[0] = true;
                                  stopRouteScan(scan);
                                  callback.onError(message);
                              }
                              return null;
                          }
                      };
      
                      listenForConnection(new ConnectionCallback() {
                          @Override
                          public void onJoin(CastSession session) {
                              sentResult[0] = true;
                              stopRouteScan(scan);
                              callback.onJoin(session);
                          }
                          @Override
                          public boolean onSessionStartFailed(int errorCode) {
                              if (errorCode == 7 || errorCode == 15) {
                                  // It network or timeout error retry
                                  retry.run();
                                  return false;
                              } else {
                                  sendErrorResult.apply(ChromecastUtilities.createError("session_error",
                                          "Failed to start session with error code: " + errorCode));
                                  return true;
                              }
                          }
                          @Override
                          public boolean onSessionEndedBeforeStart(int errorCode) {
                              if (retries[0] < 10) {
                                  retries[0]++;
                                  retry.run();
                                  return false;
                              } else {
                                  sendErrorResult.apply(ChromecastUtilities.createError("session_error",
                                          "Failed to to join existing route (" + routeId + ") " + retries[0] + 1 + " times before giving up."));
                                  return true;
                              }
                          }
                      });
      
                      startRouteScan(15000L, scan, new Runnable() {
                          @Override
                          public void run() {
                              sendErrorResult.apply(ChromecastUtilities.createError("timeout",
                                      "Failed to to join route (" + routeId + ") after 15s and " + retries[0] + 1 + " trys."));
                          }
                      });
                  }
              });
          }
      
          /**
           * Must be called from the main thread.
           * @param callback calls callback.success when we have joined, or callback.error if an error occurred
           */
          private void listenForConnection(ConnectionCallback callback) {
              // We should only ever have one of these listeners active at a time, so remove previous
              getSessionManager().removeSessionManagerListener(newConnectionListener, CastSession.class);
              newConnectionListener = new SessionListener() {
                  @Override
                  public void onSessionStarted(CastSession castSession, String sessionId) {
                      getSessionManager().removeSessionManagerListener(this, CastSession.class);
                      callback.onJoin(castSession);
                  }
                  @Override
                  public void onSessionStartFailed(CastSession castSession, int errCode) {
                      if (callback.onSessionStartFailed(errCode)) {
                          getSessionManager().removeSessionManagerListener(this, CastSession.class);
                      }
                  }
                  @Override
                  public void onSessionEnded(CastSession castSession, int errCode) {
                      if (callback.onSessionEndedBeforeStart(errCode)) {
                          getSessionManager().removeSessionManagerListener(this, CastSession.class);
                      }
                  }
              };
              getSessionManager().addSessionManagerListener(newConnectionListener, CastSession.class);
          }
      
          /**
           * Starts listening for receiver updates.
           * Must call stopRouteScan(callback) or the battery will drain with non-stop active scanning.
           * @param timeout ms until the scan automatically stops,
           *                if 0 only calls callback.onRouteUpdate once with the currently known routes
           *                if null, will scan until stopRouteScan is called
           * @param callback the callback to receive route updates on
           * @param onTimeout called when the timeout hits
           */
          public void startRouteScan(Long timeout, ScanCallback callback, Runnable onTimeout) {
              // Add the callback in active scan mode
              activity.runOnUiThread(new Runnable() {
                  public void run() {
                      callback.setMediaRouter(getMediaRouter());
      
                      if (timeout != null && timeout == 0) {
                          // Send out the one time routes
                          callback.onFilteredRouteUpdate();
                          return;
                      }
      
                      // Add the callback in active scan mode
                      getMediaRouter().addCallback(new MediaRouteSelector.Builder()
                              .addControlCategory(CastMediaControlIntent.categoryForCast(appId))
                              .build(),
                              callback,
                              MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN);
      
                      // Send out the initial routes after the callback has been added.
                      // This is important because if the callback calls stopRouteScan only once, and it
                      // happens during this call of "onFilterRouteUpdate", there must actually be an
                      // added callback to remove to stop the scan.
                      callback.onFilteredRouteUpdate();
      
                      if (timeout != null) {
                          // remove the callback after timeout ms, and notify caller
                          new Handler().postDelayed(new Runnable() {
                              @Override
                              public void run() {
                                  // And stop the scan for routes
                                  getMediaRouter().removeCallback(callback);
                                  // Notify
                                  if (onTimeout != null) {
                                      onTimeout.run();
                                  }
                              }
                          }, timeout);
                      }
                  }
              });
          }
      
          /**
           * Call to stop the active scan if any exist.
           * @param callback the callback to stop and remove
           */
          public void stopRouteScan(ScanCallback callback) {
              activity.runOnUiThread(new Runnable() {
                  public void run() {
                      callback.stop();
                      getMediaRouter().removeCallback(callback);
                  }
              });
          }
      
          /**
           * Create this empty class so that we don't have to override every function
           * each time we need a SessionManagerListener.
           */
          private class SessionListener implements SessionManagerListener<CastSession> {
              @Override
              public void onSessionStarting(CastSession castSession) { }
              @Override
              public void onSessionStarted(CastSession castSession, String sessionId) { }
              @Override
              public void onSessionStartFailed(CastSession castSession, int error) { }
              @Override
              public void onSessionEnding(CastSession castSession) { }
              @Override
              public void onSessionEnded(CastSession castSession, int error) { }
              @Override
              public void onSessionResuming(CastSession castSession, String sessionId) { }
              @Override
              public void onSessionResumed(CastSession castSession, boolean wasSuspended) { }
              @Override
              public void onSessionResumeFailed(CastSession castSession, int error) { }
              @Override
              public void onSessionSuspended(CastSession castSession, int reason) { }
          }
      
          interface SelectRouteCallback {
              void onJoin(CastSession session);
              void onError(JSONObject message);
          }
      
          interface ConnectionCallback {
              /**
               * Successfully joined a session on a route.
               * @param session the session we joined
               */
              void onJoin(CastSession session);
      
              /**
               * Called if we received an error.
               * @param errorCode You can find the error meaning here:
               *                 https://developers.google.com/android/reference/com/google/android/gms/cast/CastStatusCodes
               * @return true if we are done listening for join, false, if we to keep listening
               */
              boolean onSessionStartFailed(int errorCode);
      
              /**
               * Called when we detect a session ended event before session started.
               * See issues:
               *     https://github.com/jellyfin/cordova-plugin-chromecast/issues/49
               *     https://github.com/jellyfin/cordova-plugin-chromecast/issues/48
               * @param errorCode error to output
               * @return true if we are done listening for join, false, if we to keep listening
               */
              boolean onSessionEndedBeforeStart(int errorCode);
          }
      
          public abstract static class ScanCallback extends MediaRouter.Callback {
              /**
               * Called whenever a route is updated.
               * @param routes the currently available routes
               */
              abstract void onRouteUpdate(List<RouteInfo> routes);
      
              /** records whether we have been stopped or not. */
              private boolean stopped = false;
              /** Global mediaRouter object. */
              private MediaRouter mediaRouter;
      
              /**
               * Sets the mediaRouter object.
               * @param router mediaRouter object
               */
              void setMediaRouter(MediaRouter router) {
                  this.mediaRouter = router;
              }
      
              /**
               * Call this method when you wish to stop scanning.
               * It is important that it is called, otherwise battery
               * life will drain more quickly.
               */
              void stop() {
                  stopped = true;
              }
              private void onFilteredRouteUpdate() {
                  if (stopped || mediaRouter == null) {
                      return;
                  }
                  List<RouteInfo> outRoutes = new ArrayList<>();
                  // Filter the routes
                  for (RouteInfo route : mediaRouter.getRoutes()) {
                      // We don't want default routes, or duplicate active routes
                      // or multizone duplicates https://github.com/jellyfin/cordova-plugin-chromecast/issues/32
                      Bundle extras = route.getExtras();
                      if (extras != null) {
                          CastDevice.getFromBundle(extras);
                          if (extras.getString("com.google.android.gms.cast.EXTRA_SESSION_ID") != null) {
                              continue;
                          }
                      }
                      if (!route.isDefault()
                              && !route.getDescription().equals("Google Cast Multizone Member")
                              && route.getPlaybackType() == RouteInfo.PLAYBACK_TYPE_REMOTE
                      ) {
                          outRoutes.add(route);
                      }
                  }
                  onRouteUpdate(outRoutes);
              }
              @Override
              public final void onRouteAdded(MediaRouter router, RouteInfo route) {
                  onFilteredRouteUpdate();
              }
              @Override
              public final void onRouteChanged(MediaRouter router, RouteInfo route) {
                  onFilteredRouteUpdate();
              }
              @Override
              public final void onRouteRemoved(MediaRouter router, RouteInfo route) {
                  onFilteredRouteUpdate();
              }
          }
      
          abstract static class Listener implements CastStateListener {
              abstract void onReceiverAvailableUpdate(boolean available);
              abstract void onSessionRejoin(CastSession session);
      
              /** CastStateListener functions. */
              @Override
              public void onCastStateChanged(int state) {
                  onReceiverAvailableUpdate(state != CastState.NO_DEVICES_AVAILABLE);
              }
          }
      
      }
      

      使用 chromecast 非常有趣...

      【讨论】:

        猜你喜欢
        • 2019-08-17
        • 2017-08-16
        • 1970-01-01
        • 2020-09-23
        • 2016-10-08
        • 1970-01-01
        • 1970-01-01
        • 2017-02-09
        • 2014-07-25
        相关资源
        最近更新 更多